maj docker image + logo

This commit is contained in:
Grégory Lebreton 2024-01-22 09:48:53 +01:00
parent 953f018484
commit 3d57a2974e
91 changed files with 18513 additions and 705 deletions

432
README.md
View File

@ -1,53 +1,410 @@
# Sshwifty Web SSH & Telnet Client
Ce projet permet de déployer un client web [Sshwifty]() permettant de se connecter en SSH protéger par une authentification [Keycloak-gatekeeper]() via Keycloak
**Sshwifty is a SSH and Telnet connector made for the Web.** It can be deployed
on your computer or server to provide SSH and Telnet access interface for any
compatible (standard) web browser.
![Web Interface](Screenshot.png)
## Configurer
![Build Status](https://github.com/nirui/sshwifty/workflows/Sshwifty-CI/badge.svg)
```bash
git clone --recursive https://git.legaragenumerique.fr/GARAGENUM/sshwifty
cd sshwifty
## Install
### Binary
Compiled binaries can be found at the [release] section of the page.
Please be advised that those binaries are generated by an automatic proccess,
the author of this project will NOT verify that they work. You will have to try
it at your own risk.
[release]: https://github.com/nirui/sshwifty/releases
### Docker Image
If [Docker] is installed on your machine, you may use our prebuilt Docker Image
by executing following command:
```
$ docker run --detach \
--restart always \
--publish 8182:8182 \
--name sshwifty \
niruix/sshwifty:latest
```
### Keycloak
This will open port `8182` on the Docker host to accept traffic from all remote
clients, and serve these clients with Sshwifty instance running inside the
container.
- Créer un client "sshwifty":
Operator can also use `--publish 127.0.0.1:8182:8182` to open Sshwifty to only
local clients, which is useful if the Sshwifty instance is intended to run
behind a reverse-proxy hosted on the same host.
- Mapper aud -> clien id:
When TLS is desired and you don't want to setup Docker Volumes, you can use
`SSHWIFTY_DOCKER_TLSCERT` and `SSHWIFTY_DOCKER_TLSCERTKEY` environment variables
to import credential files to the container and automatically apply them:
### Gatekeeper
- Editer le docker-compose.yml:
```bash
nano docker-compose.yml
```
$ openssl req \
-newkey rsa:4096 -nodes -keyout domain.key -x509 -days 90 -out domain.crt
$ docker run --detach \
--restart always \
--publish 8182:8182 \
--env SSHWIFTY_DOCKER_TLSCERT="$(cat domain.crt)" \
--env SSHWIFTY_DOCKER_TLSCERTKEY="$(cat domain.key)" \
--name sshwifty \
niruix/sshwifty:latest
```
```yml
- "--discovery-url=https://<keycloak-dns>/auth/realms/<realm>/.well-known/openid-configuration"
- "--client-id=sshwifty"
- "--client-secret=<keycloak-client-secret>"
The `domain.crt` and `domain.key` must be a valid TLS certificate and key file
located on the same machine which the `docker run` command will be executed
upon.
[docker]: https://www.docker.com
### Compile from source code (Recommanded if you're a developer)
The following tools are required in order to build the software from source code:
- `git` to download the source code
- `node` and `npm` to build front-end application
- `go` to build back-end application
To start the build process, execute:
```
$ git clone https://github.com/nirui/sshwifty
$ cd sshwifty
$ npm install
$ npm run build
```
### Si reverse-proxy devant
When done, you can found the newly generated `sshwifty` binary inside current
working directory.
Notice: `Dockerfile` contains the entire build procedure of this software.
Please refer to it when you encounter any compile/build related issue.
### Third-party Homebrew Formulae from @unbeatable-101
If you're a macOS user, [@unbeatable-101] is kindly hosting a Homebrew
Formulae that allows you to install his custom Sshwifty builds for macOS via
`homebrew`. You can hop over to [unbeatable-101/homebrew-sshwifty] for detailed
instruction and contribute to his work.
Please note that, due to the third-party nature of the work, the author(s) of
Sshwifty are unable to provide any audit, warranty or support for it. If you
have any question or request regarding to the Formulae, please contact
@unbeatable-101 directly through appreciate channels.
Thank @unbeatable-101 for his work.
[@unbeatable-101]: https://github.com/unbeatable-101
[unbeatable-101/homebrew-sshwifty]: https://github.com/unbeatable-101/homebrew-sshwifty
## Configure
Sshwifty can be configured through either file or environment variables. By
default, the configuration loader will try to load file from default paths
first, when failed, environment variables will be used.
You can also specify your own configuration file by setting up `SSHWIFTY_CONFIG`
environment variable before start the software. For example:
- Ajouter les directives suivantes pour le websocket dans la config du reverse-proxy:
```
location /sshwifty/socket {
proxy_pass http://sshwifty;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
$ SSHWIFTY_CONFIG=./sshwifty.conf.json ./sshwifty
```
This way, Sshwifty will try to load the configuration from file
`./sshwifty.conf.json`, and never reach for other environment variables.
### Configuration file
Here is all the options of a configuration file:
```
{
// HTTP Host. Keep it empty to accept request from all hosts, otherwise, only
// specified host is allowed to access
"HostName": "localhost",
// Web interface access password. Set to empty to allow public access to the
// web interface (By pass the Authenticate page)
"SharedKey": "WEB_ACCESS_PASSWORD",
// Remote dial timeout. This limits how long of time the backend can spend
// to connect to a remote host. The max timeout will be determined by
// server configuration (ReadTimeout).
// (In Seconds)
"DialTimeout": 10,
// Socks5 proxy. When set, Sshwifty backend will try to connect remote through
// the given proxy
"Socks5": "localhost:1080",
// Username of the Socks5 server. Please set when needed
"Socks5User": "",
// Password of the Socks5 server. Please set when needed
"Socks5Password": "",
// Sshwifty HTTP server, you can set multiple ones to serve on different
// ports
"Servers": [
{
// Which local network interface this server will be listening
"ListenInterface": "0.0.0.0",
// Which local network port this server will be listening
"ListenPort": 8182,
// Timeout of initial request. HTTP handshake must be finished within
// this time
// (In Seconds)
"InitialTimeout": 3,
// How long do the connection can stay in idle before the backend server
// disconnects the client
// (In Seconds)
"ReadTimeout": 60,
// How long the server will wait until the client connection is ready to
// recieve new data. If this timeout is exceed, the connection will be
// closed.
// (In Seconds)
"WriteTimeout": 60,
// The interval between internal echo requests
// (In Seconds)
"HeartbeatTimeout": 20,
// Forced delay between each request
// (In Milliseconds)
"ReadDelay": 10,
// Forced delay between each write
// (In Milliseconds)
"WriteDelay": 10,
// Path to TLS certificate file. Set empty to use HTTP
"TLSCertificateFile": "",
// Path to TLS certificate key file. Set empty to use HTTP
"TLSCertificateKeyFile": ""
// Display a short text message on the Home page. Link is supported
// through `[Title text](https://link.example.com)` format
"ServerMessage": ""
},
{
"ListenInterface": "0.0.0.0",
"ListenPort": 8182,
"InitialTimeout": 3,
.....
}
],
// Remote Presets, the operater can define few presets for user so the user
// won't have to manually fill-in all the form fields
//
// Presets will be displayed in the "Known remotes" tab on the Connector
// window
//
// Notice: You can use the same JSON value for `SSHWIFTY_PRESETS` if you are
// configuring your Sshwifty through enviroment variables.
//
// Warning: Presets Data will be sent to user client WITHOUT any protection.
// DO NOT add any secret information into Preset.
//
"Presets": [
{
// Title of the preset
"Title": "SDF.org Unix Shell",
// Preset Types, i.e. Telnet, and SSH
"Type": "SSH",
// Target address and port
"Host": "sdf.org:22",
// Form fields and values, you have to manually validate the correctness
// of the field value
//
// Defining a Meta field will prevent user from changing it on their
// Connector Wizard. If you want to allow users to use their own settings,
// leave the field unsetted
//
// Values in Meta are scheme enabled, and supports following scheme
// prefixes:
// - "literal://": Text literal (Default)
// Example: literal://Data value
// (The final value will be "Data value")
// Example: literal://file:///tmp/afile
// (The final value will be "file:///tmp/afile")
// - "file://": Load Meta value from given file.
// Example: file:///home/user/.ssh/private_key
// (The file path is /home/user/.ssh/private_key)
// - "environment://": Load Meta value from an Environment Variable.
// Example: environment://PRIVATE_KEY_DATA
// (The name of the target environment variable is
// PRIVATE_KEY_DATA)
//
// All data in Meta is loaded during start up, and will not be updated
// even the source already been modified.
//
"Meta": {
// Data for predefined User field
"User": "pre-defined-username",
// Data for predefined Encoding field. Valid data is those displayed on
// the page
"Encoding": "pre-defined-encoding",
// Data for predefined Password field
"Password": "pre-defined-password",
// Data for predefined Private Key field, should contains the content
// of a Key file
"Private Key": "file:///home/user/.ssh/private_key",
// Data for predefined Authentication field. Valid values is what
// displayed on the page (Password, Private Key, None)
"Authentication": "Password",
// Data for server public key fingerprint. You can acquire the value of
// the fingerprint by manually connect to a new SSH host with Sshwifty,
// the fingerprint will be displayed on the Fingerprint comformation
// page.
"Fingerprint": "SHA256:bgO...."
}
},
{
"Title": "Endpoint Telnet",
"Type": "Telnet",
"Host": "endpoint.vaguly.com:23",
"Meta": {
// Data for predefined Encoding field. Valid data is those displayed on
// the page
"Encoding": "utf-8"
....
}
},
....
],
// Allow the Preset Remotes only, and refuse to connect to any other remote
// host
//
// NOTICE: You can only configure OnlyAllowPresetRemotes through a config
// file. This option is not supported when you are configuring with
// environment variables
"OnlyAllowPresetRemotes": false
}
```
## Déployer
`sshwifty.conf.example.json` is an example of a valid configuration file.
### Environment variables
Valid environment variables are:
```bash
docker compose up -d
```
SSHWIFTY_HOSTNAME
SSHWIFTY_SHAREDKEY
SSHWIFTY_DIALTIMEOUT
SSHWIFTY_SOCKS5
SSHWIFTY_SOCKS5_USER
SSHWIFTY_SOCKS5_PASSWORD
SSHWIFTY_LISTENPORT
SSHWIFTY_INITIALTIMEOUT
SSHWIFTY_READTIMEOUT
SSHWIFTY_WRITETIMEOUT
SSHWIFTY_HEARTBEATTIMEOUT
SSHWIFTY_READDELAY
SSHWIFTY_WRITEELAY
SSHWIFTY_LISTENINTERFACE
SSHWIFTY_TLSCERTIFICATEFILE
SSHWIFTY_TLSCERTIFICATEKEYFILE
SSHWIFTY_SERVERMESSAGE
SSHWIFTY_PRESETS
SSHWIFTY_ONLYALLOWPRESETREMOTES
```
The options they represent correspond to their counterparts in the
configuration file.
Notice: When you're using environment variables to configure Sshwifty, only one
Sshwifty HTTP server is then allowed. There is no way to setup mulitple servers
under this method of configuration. If you need to serve on multiple ports, use
the configuration file instead.
Be aware: An invalid value inside following environment variables will cause
the value to be sliently reset to default during configuration parsing phase
without warning:
```
SSHWIFTY_DIALTIMEOUT
SSHWIFTY_INITIALTIMEOUT
SSHWIFTY_READTIMEOUT
SSHWIFTY_WRITETIMEOUT
SSHWIFTY_HEARTBEATTIMEOUT
SSHWIFTY_READDELAY
SSHWIFTY_WRITEELAY
```
## FAQ
### Why the software says "The time difference is beyond operational limit"?
This usually happens when the clock on the client and/or the server is unsynced
beyond tolerance.
Please make sure the clock time on both the client and the server are correct by
resync them with a NTP server, and then reload the page. The problem should be
gone afterwards.
### Why I got error "TypeError: Cannot read property 'importKey' of undefined"
It's usually because your web browser does not support WebCrypt API (such as
`window.crypto.subtle` or anything under `window.crypto`), or the support has
been disabled.
If you're using Google Chrome, please connect Sshwifty with HTTPS. Chrome will
disable WebCrypt and many other APIs when the connection is not safe.
### Can I serve Sshwifty under a subpath such as `https://my.domain/ssh`?
The short story is NO. Sshwifty was designed based on an assumption that it will
run as the only service under a given hostname, allowing web browsers to better
enforce their data isolation rules. This is very important because Sshwifty
saves user data locally.
However, if you really want to put Sshwifty into a subpath, you can do so by
taking advantage of the fact that Sshwifty backend interface and assets are
always located under an URL prefix `/sshwifty`. You can thus redirect or proxy
those requests to their new location.
Keep in mind, doing so is really hacky, and it's not recommended by the author
thus no support will be provided if you decide to go with that.
### Why I can't add my own key combinations to the Console tool bar?
The pre-defined key combinations are there mainly to make mobile operation
possible as well as to resolve some hotkey conflicts. However, if efficiency is
your first goal, please consider to use a software/on screen keyboard which is
specially designed for terminal.
And if that's not enough, connect a physical keyboard through Bluetooth or OTA
could be a better alternative. This way you can type as if you're using a
computer console.
## Credits
- Thanks to [Ryan Fortner](https://github.com/ryanfortner) for the grammar fix
- Thanks to [Tweak](https://github.com/Tweak4141) for the grammer fix too
- Thanks to [CJendantix](https://github.com/CJendantix) for the grammer and typo
fix
## License
@ -60,7 +417,20 @@ project and read their copyright statements.
[LICENSE.md]: LICENSE.md
[DEPENDENCIES.md]: DEPENDENCIES.md
## Contribuer
## Contribute
- [ ] Config Keycloak Audience mapping
- [ ] SSHWIFTY à traduire en français
This is a hobbyist project, meaning I don't have that much time to put into it,
sorry.
Upon release (Which is then you're able to read this file), this project will
enter the _maintaining_ state, which includes doing some bug fixes and security
updates. _Adding new features however, is not a part of the state_.
Please do not send any pull requests. If you need new feature, fork it, add it
by yourself, then maintain it like one of your own project. It's not that hard
with some Github features.
(Notice: Typos, grammar errors or invalid use of language in the source code and
document is categorized as bug, please report them if you found any. Thank you!)
Appreciate your help, enjoy!

73
app.css Normal file
View File

@ -0,0 +1,73 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#app {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
position: relative;
min-width: 320px;
}
body.app-error {
background: #b44;
color: #fff;
}
#app-loading {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
justify-items: center;
justify-content: center;
}
#app-loading-frame {
flex: 0 0;
padding: 30px;
text-align: center;
}
#app-loading-icon {
background: url("./widgets/busy.svg") center center no-repeat;
background-size: contain;
width: 100%;
height: 200px;
}
#app-loading-error {
font-size: 5em;
color: #fff;
}
#app-loading-title {
color: #fab;
font-size: 1.2em;
font-weight: lighter;
max-width: 500px;
margin: 0 auto;
}
#app-loading-title.error {
color: #fff;
}

459
app.js Normal file
View File

@ -0,0 +1,459 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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"
:server-message="serverMessage"
: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: "",
serverMessage: "",
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) {
let authData = JSON.parse(authResult.data);
this.serverMessage = authData.server_message
? authData.server_message
: "";
this.presetData = {
presets: new Presets(authData.presets ? authData.presets : []),
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);

126
auth.vue Normal file
View File

@ -0,0 +1,126 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<div id="auth">
<div id="auth-frame">
<div id="auth-content">
<h1>Authentication required</h1>
<form class="form1" action="javascript:;" method="POST" @submit="auth">
<fieldset>
<div
class="field"
:class="{
error: passphraseErr.length > 0 || error.length > 0,
}"
>
Passphrase
<input
v-model="passphrase"
v-focus="true"
:disabled="submitting"
type="password"
autocomplete="off"
name="field.field.name"
placeholder="----------"
autofocus="autofocus"
/>
<div
v-if="passphraseErr.length <= 0 && error.length <= 0"
class="message"
>
A valid password is required in order to use this
<a href="https://github.com/nirui/sshwifty">Sshwifty</a>
instance
</div>
<div v-else class="error">
{{ passphraseErr || error }}
</div>
</div>
<div class="field">
<button type="submit" :disabled="submitting" @click="auth">
Authenticate
</button>
</div>
</fieldset>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
directives: {
focus: {
inserted(el, binding) {
if (!binding.value) {
return;
}
el.focus();
},
},
},
props: {
error: {
type: String,
default: "",
},
},
data() {
return {
submitting: false,
passphrase: "",
passphraseErr: "",
};
},
watch: {
error(newVal) {
if (newVal.length > 0) {
this.submitting = false;
}
},
},
mounted() {},
methods: {
auth() {
if (this.passphrase.length <= 0) {
this.passphraseErr = "Passphrase cannot be empty";
return;
}
if (this.submitting) {
return;
}
this.submitting = true;
this.passphraseErr = "";
this.$emit("auth", this.passphrase);
},
},
};
</script>

230
commands/address.js Normal file
View File

@ -0,0 +1,230 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as reader from "../stream/reader.js";
import * as common from "./common.js";
import Exception from "./exception.js";
export const LOOPBACK = 0x00;
export const IPV4 = 0x01;
export const IPV6 = 0x02;
export const HOSTNAME = 0x03;
export const MAX_ADDR_LEN = 0x3f;
export class Address {
/**
* Read builds an Address from data readed from the reader
*
* @param {reader.Reader} rd The reader
*
* @returns {Address} The Address
*
* @throws {Exception} when address type is invalid
*/
static async read(rd) {
let readed = await reader.readN(rd, 3),
portNum = 0,
addrType = LOOPBACK,
addrData = null;
portNum |= readed[0];
portNum <<= 8;
portNum |= readed[1];
addrType = readed[2] >> 6;
switch (addrType) {
case LOOPBACK:
break;
case IPV4:
addrData = await reader.readN(rd, 4);
break;
case IPV6:
addrData = await reader.readN(rd, 16);
break;
case HOSTNAME:
addrData = await reader.readN(rd, 0x3f & readed[2]);
break;
default:
throw new Exception("Unknown address type");
}
return new Address(addrType, addrData, portNum);
}
/**
* constructor
*
* @param {number} type Type of the address
* @param {Uint8Array} address Address data
* @param {number} port port number of the address
*
*/
constructor(type, address, port) {
this.addrType = type;
this.addrData = address;
this.addrPort = port;
}
/**
* Return the address type
*
*/
type() {
return this.addrType;
}
/**
* Return the address data
*
*/
address() {
return this.addrData;
}
/**
* Return the port data
*
*/
port() {
return this.addrPort;
}
/**
* Buffer returns the marshalled address
*
* @returns {Uint8Array} Marshalled address
*
* @throws {Exception} When address data is invalid
*
*/
buffer() {
switch (this.type()) {
case LOOPBACK:
return new Uint8Array([
this.addrPort >> 8,
this.addrPort & 0xff,
LOOPBACK << 6,
]);
case IPV4:
if (this.addrData.length != 4) {
throw new Exception("Invalid address length");
}
return new Uint8Array([
this.addrPort >> 8,
this.addrPort & 0xff,
IPV4 << 6,
this.addrData[0],
this.addrData[1],
this.addrData[2],
this.addrData[3],
]);
case IPV6:
if (this.addrData.length != 16) {
throw new Exception("Invalid address length");
}
return new Uint8Array([
this.addrPort >> 8,
this.addrPort & 0xff,
IPV6 << 6,
this.addrData[0],
this.addrData[1],
this.addrData[2],
this.addrData[3],
this.addrData[4],
this.addrData[5],
this.addrData[6],
this.addrData[7],
this.addrData[8],
this.addrData[9],
this.addrData[10],
this.addrData[11],
this.addrData[12],
this.addrData[13],
this.addrData[14],
this.addrData[15],
]);
case HOSTNAME:
if (this.addrData.length > MAX_ADDR_LEN) {
throw new Exception("Host name cannot longer than " + MAX_ADDR_LEN);
}
{
let dataBuf = new Uint8Array(this.addrData.length + 3);
dataBuf[0] = (this.addrPort >> 8) & 0xff;
dataBuf[1] = this.addrPort & 0xff;
dataBuf[2] = HOSTNAME << 6;
dataBuf[2] |= this.addrData.length;
dataBuf.set(this.addrData, 3);
return dataBuf;
}
default:
throw new Exception("Unknown address type");
}
}
}
/**
* Get address data
*
* @param {string} s Address string
* @param {number} defaultPort Default port number
*
* @returns {object} result
*
* @throws {Exception} when the address is invalid
*/
export function parseHostPort(s, defaultPort) {
let d = common.splitHostPort(s, defaultPort),
t = HOSTNAME;
switch (d.type) {
case "IPv4":
t = IPV4;
break;
case "IPv6":
t = IPV6;
break;
case "Hostname":
break;
default:
throw new Exception("Invalid address type");
}
return {
type: t,
address: d.addr,
port: d.port,
};
}

102
commands/address_test.js Normal file
View File

@ -0,0 +1,102 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 assert from "assert";
import * as reader from "../stream/reader.js";
import * as address from "./address.js";
describe("Address", () => {
it("Address Loopback", async () => {
let addr = new address.Address(address.LOOPBACK, null, 8080),
buf = addr.buffer();
let r = new reader.Reader(new reader.Multiple(), (data) => {
return data;
});
r.feed(buf);
let addr2 = await address.Address.read(r);
assert.strictEqual(addr2.type(), addr.type());
assert.deepStrictEqual(addr2.address(), addr.address());
assert.strictEqual(addr2.port(), addr.port());
});
it("Address IPv4", async () => {
let addr = new address.Address(
address.IPV4,
new Uint8Array([127, 0, 0, 1]),
8080,
),
buf = addr.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
r.feed(buf);
let addr2 = await address.Address.read(r);
assert.strictEqual(addr2.type(), addr.type());
assert.deepStrictEqual(addr2.address(), addr.address());
assert.strictEqual(addr2.port(), addr.port());
});
it("Address IPv6", async () => {
let addr = new address.Address(
address.IPV6,
new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]),
8080,
),
buf = addr.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
r.feed(buf);
let addr2 = await address.Address.read(r);
assert.strictEqual(addr2.type(), addr.type());
assert.deepStrictEqual(addr2.address(), addr.address());
assert.strictEqual(addr2.port(), addr.port());
});
it("Address HostName", async () => {
let addr = new address.Address(
address.HOSTNAME,
new Uint8Array(["v", "a", "g", "u", "l", "1", "2", "3"]),
8080,
),
buf = addr.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
r.feed(buf);
let addr2 = await address.Address.read(r);
assert.strictEqual(addr2.type(), addr.type());
assert.deepStrictEqual(addr2.address(), addr.address());
assert.strictEqual(addr2.port(), addr.port());
});
});

107
commands/color.js Normal file
View File

@ -0,0 +1,107 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
/**
* Get one color hex byte
*
* @param {number} from Min color number
* @param {number} to Max color number
*
* @returns {string} color byte in string
*
*/
function getRandHex(from, to) {
let color = Math.random() * (to - from) + from,
colorDark = color - color / 20;
let r = Math.round(color).toString(16),
rDark = Math.round(colorDark).toString(16);
if (r.length % 2 !== 0) {
r = "0" + r;
}
if (rDark.length % 2 !== 0) {
rDark = "0" + rDark;
}
return [r, rDark];
}
/**
* Get rand color
*
* @param {number} from Min color number
* @param {number} to Max color number
*
* @returns {string} Color bytes in string
*/
function getRandColor(from, to) {
let r = getRandHex(from, to),
g = getRandHex(from, to),
b = getRandHex(from, to);
return ["#" + r[0] + g[0] + b[0], "#" + r[1] + g[1] + b[1]];
}
export class Color {
/**
* constructor
*/
constructor() {
this.assignedColors = {};
}
/**
* Get one color
*
* @returns {string} Color code
*
*/
get() {
const maxTries = 10;
let tried = 0;
for (;;) {
let color = getRandColor(0x22, 0x33);
if (this.assignedColors[color[0]]) {
tried++;
if (tried < maxTries) {
continue;
}
}
this.assignedColors[color[0]] = true;
return {
color: color[0],
dark: color[1],
};
}
}
/**
* forget already assigned color
*
* @param {string} color Color code
*/
forget(color) {
delete this.assignedColors[color];
}
}

880
commands/commands.js Normal file
View File

@ -0,0 +1,880 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as subscribe from "../stream/subscribe.js";
import Exception from "./exception.js";
import * as presets from "./presets.js";
export const NEXT_PROMPT = 1;
export const NEXT_WAIT = 2;
export const NEXT_DONE = 3;
export class Result {
/**
* constructor
*
* @param {string} name Result type
* @param {Info} info Result info
* @param {object} control Result controller
* @param {string} ui User interfact this command will use
*/
constructor(name, info, control, ui) {
this.name = name;
this.info = info;
this.control = control;
this.ui = ui;
}
}
class Done {
/**
* constructor
*
* @param {object} data Step data
*
*/
constructor(data) {
this.s = !!data.success;
this.d = data.successData;
this.errorTitle = data.errorTitle;
this.errorMessage = data.errorMessage;
}
/**
* Return the error of current Done
*
* @returns {string} title
*
*/
error() {
return this.errorTitle;
}
/**
* Return the error message of current Done
*
* @returns {string} message
*
*/
message() {
return this.errorMessage;
}
/**
* Returns whether or not current Done is representing a success
*
* @returns {boolean} True when success, false otherwise
*/
success() {
return this.s;
}
/**
* Returns final data
*
* @returns {Result} Successful result
*/
data() {
return this.d;
}
}
class Wait {
/**
* constructor
*
* @param {object} data Step data
*
*/
constructor(data) {
this.t = data.title;
this.m = data.message;
}
/**
* Return the title of current Wait
*
* @returns {string} title
*
*/
title() {
return this.t;
}
/**
* Return the message of current Wait
*
* @returns {string} message
*
*/
message() {
return this.m;
}
}
const defField = {
name: "",
description: "",
type: "",
value: "",
example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(v) {
return "";
},
};
/**
* Create a Prompt field
*
* @param {object} def Field default value
* @param {object} f Field value
*
* @returns {object} Field data
*
* @throws {Exception} When input field is invalid
*
*/
export function field(def, f) {
let n = {};
for (let i in def) {
n[i] = def[i];
}
for (let i in f) {
if (typeof n[i] === typeof f[i]) {
n[i] = f[i];
continue;
}
throw new Exception(
'Field data type for "' +
i +
'" was unmatched. Expecting "' +
typeof n[i] +
'", got "' +
typeof f[i] +
'" instead',
);
}
if (!n["name"]) {
throw new Exception('Field "name" must be specified');
}
return n;
}
/**
* Build a group of field value
*
* @param {object} definitions Definition of a group of fields
* @param {Array<object>} fs Data of the field group
*
* @returns {Array<object>} Result fields
*
* @throws {Exception} When input field is invalid
*
*/
export function fields(definitions, fs) {
let fss = [];
for (let i in fs) {
if (!fs[i]["name"]) {
throw new Exception('Field "name" must be specified');
}
if (!definitions[fs[i].name]) {
throw new Exception('Undefined field "' + fs[i].name + '"');
}
fss.push(field(definitions[fs[i].name], fs[i]));
}
return fss;
}
/**
* Build command fields with preset data
*
* @param {object} definitions Definition of a group of fields
* @param {object} fieldsData field data object, formated like a `defField`
* @param {presets.Preset} presetData Preset data
* @param {function} presetApplied Called when a preset is used for a field
*
* @returns {object}
*
*/
export function fieldsWithPreset(
definitions,
fieldsData,
presetData,
presetApplied,
) {
let newFields = fields(definitions, fieldsData);
for (let i in newFields) {
try {
newFields[i].value = presetData.meta(newFields[i].name);
newFields[i].readonly = true;
presetApplied(newFields[i].name);
} catch (e) {
// Do nothing
}
}
return newFields;
}
class Prompt {
/**
* constructor
*
* @param {object} data Step data
*
* @throws {Exception} If the field verify is not a function while
* not null
*/
constructor(data) {
this.t = data.title;
this.m = data.message;
this.a = data.actionText;
this.r = data.respond;
this.c = data.cancel;
this.i = [];
this.f = {};
for (let i in data.inputs) {
let f = field(defField, data.inputs[i]);
this.i.push(f);
this.f[data.inputs[i].name.toLowerCase()] = {
value: f.value,
verify: f.verify,
};
}
}
/**
* Return the title of current Prompt
*
* @returns {string} title
*
*/
title() {
return this.t;
}
/**
* Return the message of current Prompt
*
* @returns {string} message
*
*/
message() {
return this.m;
}
/**
* Return the input field of current prompt
*
* @returns {array} Input fields
*
*/
inputs() {
let inputs = [];
for (let i in this.i) {
inputs.push(this.i[i]);
}
return inputs;
}
/**
* Returns the name of the action
*
* @returns {string} Action name
*
*/
actionText() {
return this.a;
}
/**
* Receive the submit of current prompt
*
* @param {object} inputs Input value
*
* @returns {any} The result of the step responder
*
* @throws {Exception} When the field is undefined or invalid
*
*/
submit(inputs) {
let fields = {};
for (let i in this.f) {
fields[i] = this.f[i].value;
}
for (let i in inputs) {
let k = i.toLowerCase();
if (typeof fields[k] === "undefined") {
throw new Exception('Field "' + k + '" is undefined');
}
try {
this.f[k].verify(inputs[i]);
} catch (e) {
throw new Exception('Field "' + k + '" is invalid: ' + e);
}
fields[k] = inputs[i];
}
return this.r(fields);
}
/**
* Cancel current wait operation
*
*/
cancel() {
return this.c();
}
}
/**
* Create a Wizard step
*
* @param {string} type Step type
* @param {object} data Step data
*
* @returns {object} Step data
*
*/
function next(type, data) {
return {
type() {
return type;
},
data() {
return data;
},
};
}
/**
* Create data for a Done step of the wizard
*
* @param {boolean} success
* @param {Success} successData
* @param {string} errorTitle
* @param {string} errorMessage
*
* @returns {object} Done step data
*
*/
export function done(success, successData, errorTitle, errorMessage) {
return next(NEXT_DONE, {
success: success,
successData: successData,
errorTitle: errorTitle,
errorMessage: errorMessage,
});
}
/**
* Create data for a Wait step of the wizard
*
* @param {string} title Waiter title
* @param {message} message Waiter message
*
* @returns {object} Done step data
*
*/
export function wait(title, message) {
return next(NEXT_WAIT, {
title: title,
message: message,
});
}
/**
* Create data for a Prompt step of the wizard
*
* @param {string} title Title of the prompt
* @param {string} message Message of the prompt
* @param {string} actionText Text of the action (button)
* @param {function} respond Respond callback
* @param {function} cancel cancel handler
* @param {object} inputs Input field objects
*
* @returns {object} Prompt step data
*
*/
export function prompt(title, message, actionText, respond, cancel, inputs) {
return next(NEXT_PROMPT, {
title: title,
message: message,
actionText: actionText,
inputs: inputs,
respond: respond,
cancel: cancel,
});
}
class Next {
/**
* constructor
*
* @param {object} data Step data
*/
constructor(data) {
this.t = data.type();
this.d = data.data();
}
/**
* Return step type
*
* @returns {string} Step type
*/
type() {
return this.t;
}
/**
* Return step data
*
* @returns {Done|Prompt} Step data
*
* @throws {Exception} When the step type is unknown
*
*/
data() {
switch (this.type()) {
case NEXT_PROMPT:
return new Prompt(this.d);
case NEXT_WAIT:
return new Wait(this.d);
case NEXT_DONE:
return new Done(this.d);
default:
throw new Exception("Unknown data type");
}
}
}
class Wizard {
/**
* constructor
*
* @param {object} built Command executer
* @param {subscribe.Subscribe} subs Wizard step subscriber
* @param {function} done Callback which will be called when the wizard
* is done
*
*/
constructor(built, subs, done) {
this.built = built;
this.subs = subs;
this.done = done;
this.closed = false;
this.built.run();
}
/**
* Return the Next step
*
* @returns {Next} Next step
*
* @throws {Exception} When wizard is closed
*
*/
async next() {
if (this.closed) {
throw new Exception("Wizard already closed, no next step is available");
}
let n = await this.subs.subscribe();
if (n.type() === NEXT_DONE) {
this.close();
this.done(n);
}
return new Next(n);
}
/**
* Return whether or not the command is started
*
* @returns {boolean} True when the command already started, false otherwise
*
*/
started() {
return this.built.started();
}
/**
* Return the name of the control info of current wizard
*
* @returns {object}
*
*/
control() {
return this.built.control();
}
/**
* Close current wizard
*
* @returns {any} Close result
*
*/
close() {
if (this.closed) {
return;
}
this.closed = true;
return this.built.close();
}
}
export class Info {
/**
* constructor
*
* @param {Builder} info Builder info
*
*/
constructor(info) {
this.type = info.name();
this.info = info.description();
this.tcolor = info.color();
}
/**
* Return command name
*
* @returns {string} Command name
*
*/
name() {
return this.type;
}
/**
* Return command description
*
* @returns {string} Command description
*
*/
description() {
return this.info;
}
/**
* Return the theme color of the command
*
* @returns {string} Command name
*
*/
color() {
return this.tcolor;
}
}
class Builder {
/**
* constructor
*
* @param {object} command Command builder
*
*/
constructor(command) {
this.cid = command.id();
this.represeter = (n) => {
return command.represet(n);
};
this.wizarder = (n, i, r, u, y, x, l, p) => {
return command.wizard(n, i, r, u, y, x, l, p);
};
this.executer = (n, i, r, u, y, x, l, p) => {
return command.execute(n, i, r, u, y, x, l, p);
};
this.launchCmd = (n, i, r, u, y, x) => {
return command.launch(n, i, r, u, y, x);
};
this.launcherCmd = (c) => {
return command.launcher(c);
};
this.type = command.name();
this.info = command.description();
this.tcolor = command.color();
}
/**
* Return the command ID
*
* @returns {number} Command ID
*
*/
id() {
return this.cid;
}
/**
* Return command name
*
* @returns {string} Command name
*
*/
name() {
return this.type;
}
/**
* Return command description
*
* @returns {string} Command description
*
*/
description() {
return this.info;
}
/**
* Return the theme color of the command
*
* @returns {string} Command name
*
*/
color() {
return this.tcolor;
}
/**
* Execute an automatic command wizard
*
* @param {stream.Streams} streams
* @param {controls.Controls} controls
* @param {history.History} history
* @param {presets.Preset} preset
* @param {object} session
* @param {Array<string>} keptSessions
* @param {function} done Callback which will be called when wizard is done
*
* @returns {Wizard} Command wizard
*
*/
wizard(streams, controls, history, preset, session, keptSessions, done) {
let subs = new subscribe.Subscribe();
return new Wizard(
this.wizarder(
new Info(this),
preset,
session,
keptSessions,
streams,
subs,
controls,
history,
),
subs,
done,
);
}
/**
* Execute an automatic command wizard
*
* @param {stream.Streams} streams
* @param {controls.Controls} controls
* @param {history.History} history
* @param {object} config
* @param {object} session
* @param {Array<string>} keptSessions
* @param {function} done Callback which will be called when wizard is done
*
* @returns {Wizard} Command wizard
*
*/
execute(streams, controls, history, config, session, keptSessions, done) {
let subs = new subscribe.Subscribe();
return new Wizard(
this.executer(
new Info(this),
config,
session,
keptSessions,
streams,
subs,
controls,
history,
),
subs,
done,
);
}
/**
* Launch command wizard out of given launcher string
*
* @param {stream.Streams} streams
* @param {controls.Controls} controls
* @param {history.History} history
* @param {string} launcher Launcher format
* @param {function} done Callback which will be called when launching is done
*
* @returns {Wizard} Command wizard
*
*/
launch(streams, controls, history, launcher, done) {
let subs = new subscribe.Subscribe();
return new Wizard(
this.launchCmd(
new Info(this),
decodeURI(launcher),
streams,
subs,
controls,
history,
),
subs,
done,
);
}
/**
* Build launcher string out of given config
*
* @param {object} config Configuration object
*
* @return {string} Launcher string
*/
launcher(config) {
return this.name() + ":" + encodeURI(this.launcherCmd(config));
}
/**
* Reconfigure the preset data for the command wizard
*
* @param {presets.Preset} n preset
*
* @return {presets.Preset} modified new preset
*/
represet(n) {
return this.represeter(n);
}
}
export class Preset {
/**
* constructor
*
* @param {presets.Preset} preset preset
* @param {Builder} command executor
*
*/
constructor(preset, command) {
this.preset = preset;
this.command = command;
}
}
export class Commands {
/**
* constructor
*
* @param {Array<object>} commands Command array
*
*/
constructor(commands) {
this.commands = [];
for (let i = 0; i < commands.length; i++) {
this.commands.push(new Builder(commands[i]));
}
}
/**
* Return all commands
*
* @returns {Array<Builder>} A group of command
*
*/
all() {
return this.commands;
}
/**
* Select one command
*
* @param {number} id Command ID
*
* @returns {Builder} Command builder
*
*/
select(id) {
return this.commands[id];
}
/**
* Returns presets with merged command
*
* @param {presets.Presets} ps
*
* @returns {Array<Preset>}
*
*/
mergePresets(ps) {
let pp = [];
for (let i = 0; i < this.commands.length; i++) {
const fetched = ps.fetch(this.commands[i].name());
for (let j = 0; j < fetched.length; j++) {
pp.push(
new Preset(this.commands[i].represet(fetched[j]), this.commands[i]),
);
}
}
return pp;
}
}

409
commands/common.js Normal file
View File

@ -0,0 +1,409 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as buffer from "buffer/";
import * as iconv from "iconv-lite";
import Exception from "./exception.js";
const availableEncodings = [
"utf-8",
"ibm866",
"iso-8859-2",
"iso-8859-3",
"iso-8859-4",
"iso-8859-5",
"iso-8859-6",
"iso-8859-7",
"iso-8859-8",
"iso-8859-10",
"iso-8859-13",
"iso-8859-14",
"iso-8859-15",
"iso-8859-16",
"koi8-r",
"koi8-u",
"macintosh",
"windows-874",
"windows-1250",
"windows-1251",
"windows-1252",
"windows-1253",
"windows-1254",
"windows-1255",
"windows-1256",
"windows-1257",
"windows-1258",
"gbk",
"gb18030",
"big5",
"euc-jp",
"shift-jis",
"euc-kr",
"utf-16be",
"utf-16le",
];
export const charsetPresets = (() => {
let r = [];
for (let i in availableEncodings) {
try {
if (!iconv.encodingExists(availableEncodings[i])) {
continue;
}
new TextDecoder(availableEncodings[i]);
r.push(availableEncodings[i]);
} catch (e) {
// Do nothing
}
}
return r;
})();
const numCharators = {
0: true,
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
7: true,
8: true,
9: true,
};
const hexCharators = {
0: true,
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
7: true,
8: true,
9: true,
a: true,
b: true,
c: true,
d: true,
e: true,
f: true,
};
/**
* Test whether or not given string is all number
*
* @param {string} d Input data
*
* @returns {boolean} Return true if given string is all number, false otherwise
*
*/
export function isNumber(d) {
for (let i = 0; i < d.length; i++) {
if (!numCharators[d[i]]) {
return false;
}
}
return true;
}
/**
* Test whether or not given string is all hex
*
* @param {string} d Input data
*
* @returns {boolean} Return true if given string is all hex, false otherwise
*
*/
export function isHex(d) {
let dd = d.toLowerCase();
for (let i = 0; i < dd.length; i++) {
if (!hexCharators[dd[i]]) {
return false;
}
}
return true;
}
/**
* Test whether or not given string is a valid hostname as far as the Sshwifty
* client consider. This function will return true if the string contains only
* printable charactors
*
* @param {string} d Input data
*
* @returns {boolean} Return true if given string is all hex, false otherwise
*
*/
function isHostname(d) {
for (let i = 0; i < d.length; i++) {
const dChar = d.charCodeAt(i);
if (dChar >= 32 && dChar <= 126) {
continue;
}
if (dChar === 128) {
continue;
}
if (dChar >= 130 && dChar <= 140) {
continue;
}
if (dChar === 142) {
continue;
}
if (dChar >= 145 && dChar <= 156) {
continue;
}
if (dChar >= 158 && dChar <= 159) {
continue;
}
if (dChar >= 161 && dChar <= 255) {
continue;
}
return false;
}
return true;
}
/**
* Parse IPv4 address
*
* @param {string} d IP address
*
* @returns {Uint8Array} Parsed IPv4 Address
*
* @throws {Exception} When the given ip address was not an IPv4 addr
*
*/
export function parseIPv4(d) {
const addrSeg = 4;
let s = d.split(".");
if (s.length != addrSeg) {
throw new Exception("Invalid address");
}
let r = new Uint8Array(addrSeg);
for (let i in s) {
if (!isNumber(s[i])) {
throw new Exception("Invalid address");
}
let ii = parseInt(s[i], 10); // Only support dec
if (isNaN(ii)) {
throw new Exception("Invalid address");
}
if (ii > 0xff) {
throw new Exception("Invalid address");
}
r[i] = ii;
}
return r;
}
/**
* Parse IPv6 address. ::ffff: notation is NOT supported
*
* @param {string} d IP address
*
* @returns {Uint8Array} Parsed IPv6 Address
*
* @throws {Exception} When the given ip address was not an IPv6 addr
*
*/
export function parseIPv6(d) {
const addrSeg = 8;
let s = d.split(":");
if (s.length > addrSeg || s.length <= 1) {
throw new Exception("Invalid address");
}
if (s[0].charAt(0) === "[") {
s[0] = s[0].substring(1, s[0].length);
let end = s.length - 1;
if (s[end].charAt(s[end].length - 1) !== "]") {
throw new Exception("Invalid address");
}
s[end] = s[end].substring(0, s[end].length - 1);
}
let r = new Uint8Array(addrSeg * 2),
rIndexShift = 0;
for (let i = 0; i < s.length; i++) {
if (s[i].length <= 0) {
rIndexShift = addrSeg - s.length;
continue;
}
if (!isHex(s[i])) {
throw new Exception("Invalid address");
}
let ii = parseInt(s[i], 16); // Only support hex
if (isNaN(ii)) {
throw new Exception("Invalid address");
}
if (ii > 0xffff) {
throw new Exception("Invalid address");
}
let j = (rIndexShift + i) * 2;
r[j] = ii >> 8;
r[j + 1] = ii & 0xff;
}
return r;
}
/**
* Convert string into a {Uint8Array}
*
* @param {string} d Input
*
* @returns {Uint8Array} Output
*
*/
export function strToUint8Array(d) {
let r = new Uint8Array(d.length);
for (let i = 0, j = d.length; i < j; i++) {
r[i] = d.charCodeAt(i);
}
return r;
}
/**
* Convert string into a binary {Uint8Array}
*
* @param {string} d Input
*
* @returns {Uint8Array} Output
*
*/
export function strToBinary(d) {
return new Uint8Array(buffer.Buffer.from(d, "binary").buffer);
}
/**
* Parse IPv6 address. ::ffff: notation is NOT supported
*
* @param {string} d IP address
*
* @returns {Uint8Array} Parsed IPv6 Address
*
* @throws {Exception} When the given ip address was not an IPv6 addr
*
*/
export function parseHostname(d) {
if (d.length <= 0) {
throw new Exception("Invalid address");
}
if (!isHostname(d)) {
throw new Exception("Invalid address");
}
return strToUint8Array(d);
}
function parseIP(d) {
try {
return {
type: "IPv4",
data: parseIPv4(d),
};
} catch (e) {
// Do nothing
}
try {
return {
type: "IPv6",
data: new Uint8Array(parseIPv6(d).buffer),
};
} catch (e) {
// Do nothing
}
return {
type: "Hostname",
data: parseHostname(d),
};
}
export function splitHostPort(d, defPort) {
let hps = d.lastIndexOf(":"),
fhps = d.indexOf(":"),
ipv6hps = d.indexOf("[");
if ((hps < 0 || hps != fhps) && ipv6hps < 0) {
let a = parseIP(d);
return {
type: a.type,
addr: a.data,
port: defPort,
};
}
if (ipv6hps > 0) {
throw new Exception("Invalid address");
} else if (ipv6hps === 0) {
let ipv6hpse = d.lastIndexOf("]");
if (ipv6hpse <= ipv6hps || ipv6hpse + 1 != hps) {
throw new Exception("Invalid address");
}
}
let addr = d.slice(0, hps),
port = d.slice(hps + 1, d.length);
if (!isNumber(port)) {
throw new Exception("Invalid address");
}
let portNum = parseInt(port, 10),
a = parseIP(addr);
return {
type: a.type,
addr: a.data,
port: portNum,
};
}

246
commands/common_test.js Normal file
View File

@ -0,0 +1,246 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 assert from "assert";
import * as common from "./common.js";
describe("Common", () => {
it("parseIPv4", () => {
let tests = [
{
sample: "127.0.0.1",
expectingFailure: false,
expected: new Uint8Array([127, 0, 0, 1]),
},
{
sample: "255.255.255.255",
expectingFailure: false,
expected: new Uint8Array([255, 255, 255, 255]),
},
{
sample: "255.255.a.255",
expectingFailure: true,
expected: null,
},
{
sample: "255.255.255",
expectingFailure: true,
expected: null,
},
{
sample: "2001:db8:1f70::999:de8:7648:6e8",
expectingFailure: true,
expected: null,
},
{
sample: "a.ssh.vaguly.com",
expectingFailure: true,
expected: null,
},
];
for (let i in tests) {
if (tests[i].expectingFailure) {
let ee = null;
try {
common.parseIPv4(tests[i].sample);
} catch (e) {
ee = e;
}
assert.notStrictEqual(ee, null, "Test " + tests[i].sample);
} else {
let data = common.parseIPv4(tests[i].sample);
assert.deepStrictEqual(data, tests[i].expected);
}
}
});
it("parseIPv6", () => {
let tests = [
{
sample: "2001:db8:1f70:0:999:de8:7648:6e8",
expectingFailure: false,
expected: new Uint8Array([
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
0x76, 0x48, 0x6, 0xe8,
]),
},
{
sample: "2001:db8:85a3::8a2e:370:7334",
expectingFailure: false,
expected: new Uint8Array([
0x20, 0x01, 0xd, 0xb8, 0x85, 0xa3, 0x0, 0x0, 0x0, 0x0, 0x8a, 0x2e,
0x3, 0x70, 0x73, 0x34,
]),
},
{
sample: "fdef:90fb:4138::8ca",
expectingFailure: false,
expected: new Uint8Array([
0xfd, 0xef, 0x90, 0xfb, 0x41, 0x38, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x8, 0xca,
]),
},
{
sample: "::1",
expectingFailure: false,
expected: new Uint8Array([
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x1,
]),
},
{
sample: "::",
expectingFailure: false,
expected: new Uint8Array([
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0,
]),
},
{
sample: "2001:db8:1f70::999:de8:7648:6e8",
expectingFailure: false,
expected: new Uint8Array([
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
0x76, 0x48, 0x6, 0xe8,
]),
},
{
sample: "2001:0db8:ac10:fe01::",
expectingFailure: false,
expected: new Uint8Array([
0x20, 0x01, 0x0d, 0xb8, 0xac, 0x10, 0xfe, 0x01, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0,
]),
},
{
sample: "::7f00:1",
expectingFailure: false,
expected: new Uint8Array([
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7f,
0x00, 0x00, 0x01,
]),
},
{
sample: "127.0.0.1",
expectingFailure: true,
expected: null,
},
{
sample: "255.255.255.255",
expectingFailure: true,
expected: null,
},
{
sample: "255.255.a.255",
expectingFailure: true,
expected: null,
},
{
sample: "255.255.255",
expectingFailure: true,
expected: null,
},
{
sample: "a.ssh.vaguly.com",
expectingFailure: true,
expected: null,
},
];
for (let i in tests) {
if (tests[i].expectingFailure) {
let ee = null;
try {
common.parseIPv6(tests[i].sample);
} catch (e) {
ee = e;
}
assert.notStrictEqual(ee, null, "Test " + tests[i].sample);
} else {
let data = common.parseIPv6(tests[i].sample);
assert.deepStrictEqual(data, tests[i].expected);
}
}
});
it("splitHostPort", () => {
let tests = [
// Host name
{
sample: "ssh.vaguly.com",
expectedType: "Hostname",
expectedAddr: common.strToUint8Array("ssh.vaguly.com"),
expectedPort: 22,
},
{
sample: "ssh.vaguly.com:22",
expectedType: "Hostname",
expectedAddr: common.strToUint8Array("ssh.vaguly.com"),
expectedPort: 22,
},
// IPv4
{
sample: "10.220.179.110",
expectedType: "IPv4",
expectedAddr: new Uint8Array([10, 220, 179, 110]),
expectedPort: 22,
},
{
sample: "10.220.179.110:3333",
expectedType: "IPv4",
expectedAddr: new Uint8Array([10, 220, 179, 110]),
expectedPort: 3333,
},
// IPv6
{
sample: "2001:db8:1f70::999:de8:7648:6e8",
expectedType: "IPv6",
expectedAddr: new Uint8Array([
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
0x76, 0x48, 0x6, 0xe8,
]),
expectedPort: 22,
},
{
sample: "[2001:db8:1f70::999:de8:7648:6e8]:100",
expectedType: "IPv6",
expectedAddr: new Uint8Array([
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
0x76, 0x48, 0x6, 0xe8,
]),
expectedPort: 100,
},
];
for (let i in tests) {
let hostport = common.splitHostPort(tests[i].sample, 22);
assert.deepStrictEqual(hostport.type, tests[i].expectedType);
assert.deepStrictEqual(hostport.addr, tests[i].expectedAddr);
assert.strictEqual(hostport.port, tests[i].expectedPort);
}
});
});

60
commands/controls.js vendored Normal file
View File

@ -0,0 +1,60 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 Exception from "./exception.js";
export class Controls {
/**
* constructor
*
* @param {[]object} controls
*
* @throws {Exception} When control type already been defined
*
*/
constructor(controls) {
this.controls = {};
for (let i in controls) {
let cType = controls[i].type();
if (typeof this.controls[cType] === "object") {
throw new Exception('Control "' + cType + '" already been defined');
}
this.controls[cType] = controls[i];
}
}
/**
* Get a control
*
* @param {string} type Type of the control
*
* @returns {object} Control object
*
* @throws {Exception} When given control type is undefined
*
*/
get(type) {
if (typeof this.controls[type] !== "object") {
throw new Exception('Control "' + type + '" was undefined');
}
return this.controls[type];
}
}

106
commands/events.js Normal file
View File

@ -0,0 +1,106 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 Exception from "./exception.js";
export class Events {
/**
* constructor
*
* @param {[]string} events required events
* @param {object} callbacks Callbacks
*
* @throws {Exception} When event handler is not registered
*
*/
constructor(events, callbacks) {
this.events = {};
this.placeHolders = {};
for (let i in events) {
if (typeof callbacks[events[i]] !== "function") {
throw new Exception(
'Unknown event type for "' +
events[i] +
'". Expecting "function" got "' +
typeof callbacks[events[i]] +
'" instead.',
);
}
let name = events[i];
if (name.indexOf("@") === 0) {
name = name.substring(1);
this.placeHolders[name] = null;
}
this.events[name] = callbacks[events[i]];
}
}
/**
* Place callbacks to pending placeholder events
*
* @param {string} type Event Type
* @param {function} callback Callback function
*/
place(type, callback) {
if (this.placeHolders[type] !== null) {
throw new Exception(
'Event type "' +
type +
'" cannot be appended. It maybe ' +
"unregistered or already been acquired",
);
}
if (typeof callback !== "function") {
throw new Exception(
'Unknown event type for "' +
type +
'". Expecting "function" got "' +
typeof callback +
'" instead.',
);
}
delete this.placeHolders[type];
this.events[type] = callback;
}
/**
* Fire an event
*
* @param {string} type Event type
* @param {...any} data Event data
*
* @returns {any} The result of the event handler
*
* @throws {Exception} When event type is not registered
*
*/
fire(type, ...data) {
if (!this.events[type] && this.placeHolders[type] !== null) {
throw new Exception("Unknown event type: " + type);
}
return this.events[type](...data);
}
}

28
commands/exception.js Normal file
View File

@ -0,0 +1,28 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
export default class Exception extends Error {
/**
* constructor
*
* @param {string} message error message
*
*/
constructor(message) {
super(message);
}
}

311
commands/history.js Normal file
View File

@ -0,0 +1,311 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as command from "./commands.js";
/**
* Extract needed data
*
* @param {Array<string>} kept The keys of of the data to be kept
* @param {object} input Input data
*
* @return {object} Extracted data
*/
function extractSelectedData(kept, input) {
if (!kept || typeof kept !== "object" || kept.length < 0) {
return null;
}
let data = {},
length = 0;
for (let k in kept) {
if (!input[kept[k]]) {
continue;
}
data[kept[k]] = input[kept[k]];
length++;
}
if (length <= 0) {
return null;
}
return data;
}
/**
* constructor
*
* @param {object} data to be searched
* @param {string} metaName name of the meta
* @param {string} valContains target string to search
*
*/
function metaContains(data, metaName, valContains) {
switch (typeof data[metaName]) {
case "string":
return data[metaName].indexOf(valContains) >= 0;
default:
return false;
}
}
export class History {
/**
* constructor
*
* @param {Array<object>} records
* @param {function} saver
* @param {number} maxItems
*
*/
constructor(records, saver, maxItems) {
this.records = records;
this.maxItems = maxItems;
this.saver = saver;
}
/**
* Return the index of given uname, or -1 when not found
*
* @param {string} uname the unique name
*
* @returns {integer} The index of given uname
*
*/
indexOf(uname) {
for (let i in this.records) {
if (this.records[i].uname !== uname) {
continue;
}
return i;
}
return -1;
}
/**
* Save record to history
*
* @param {string} uname unique name
* @param {string} title Title
* @param {command.Info} info Command info
* @param {Date} lastUsed Last used
* @param {object} data Data
* @param {object} sessionData Data which only available for current session
* @param {Array<string>} keptSessions Keys of the session data that should
* be saved
*
*/
save(uname, title, lastUsed, info, data, sessionData, keptSessions) {
const unameIdx = this.indexOf(uname);
if (unameIdx >= 0) {
this.records.splice(unameIdx, 1);
}
this.records.push({
uname: uname,
title: title,
type: info.name(),
color: info.color(),
last: lastUsed.getTime(),
data: data,
session: sessionData,
keptSessions: keptSessions,
});
if (this.records.length > this.maxItems) {
this.records = this.records.slice(
this.records.length - this.maxItems,
this.records.length,
);
}
this.store();
}
/**
* Save current records to storage
*
*/
store() {
this.saver(this, this.export());
}
/**
* Delete record from history
*
* @param {string} uid unique name
*
*/
del(uid) {
for (let i in this.records) {
if (this.records[i].uname !== uid) {
continue;
}
this.records.splice(i, 1);
break;
}
this.saver(this, this.records);
}
/**
* Clear session data
*
* @param {string} uid unique name
*
*/
clearSession(uid) {
for (let i in this.records) {
if (this.records[i].uname !== uid) {
continue;
}
this.records[i].session = null;
this.records[i].keptSessions = [];
break;
}
this.store();
}
/**
* Return all history records. The exported data is differ than the
* internal ones, it cannot be directly import back
*
* @returns {Array<object>} Records
*
*/
all() {
let r = [];
for (let i in this.records) {
r.push({
uid: this.records[i].uname,
title: this.records[i].title,
type: this.records[i].type,
color: this.records[i].color,
last: new Date(this.records[i].last),
data: this.records[i].data,
session: this.records[i].session,
keptSessions: this.records[i].keptSessions,
});
}
return r;
}
/**
* Export current history records
*
* @returns {Array<object>} Records
*
*/
export() {
let r = [];
for (let i in this.records) {
r.push({
uname: this.records[i].uname,
title: this.records[i].title,
type: this.records[i].type,
color: this.records[i].color,
last: this.records[i].last,
data: this.records[i].data,
session: extractSelectedData(
this.records[i].keptSessions,
this.records[i].session,
),
keptSessions: this.records[i].keptSessions,
});
}
return r;
}
/**
* Import data into current history records
*
* @param {Array<object>} records Records
*
*/
import(records) {
for (let i in records) {
if (this.indexOf(records[i].uname) >= 0) {
continue;
}
this.records.push({
uname: records[i].uname,
title: records[i].title,
type: records[i].type,
color: records[i].color,
last: records[i].last,
data: records[i].data,
session: extractSelectedData(
records[i].keptSessions,
records[i].session,
),
keptSessions: records[i].keptSessions,
});
}
this.store();
}
/**
* Search for partly matched results
*
* @param {string} type of the history record
* @param {string} metaName name of the meta data
* @param {string} keyword keyword to search
* @param {number} max max results
*/
search(type, metaName, keyword, max) {
let maxResults = max > this.records.length ? this.records.length : max;
let s = [];
if (maxResults < 0) {
maxResults = this.records.length;
}
for (let i = 0; i < this.records.length && s.length < maxResults; i++) {
if (this.records[i].type !== type) {
continue;
}
if (!this.records[i].data) {
continue;
}
if (!metaContains(this.records[i].data, metaName, keyword)) {
continue;
}
s.push(this.records[i]);
}
return s;
}
}

90
commands/integer.js Normal file
View File

@ -0,0 +1,90 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as reader from "../stream/reader.js";
import Exception from "./exception.js";
export const MAX = 0x3fff;
export const MAX_BYTES = 2;
const integerHasNextBit = 0x80;
const integerValueCutter = 0x7f;
export class Integer {
/**
* constructor
*
* @param {number} num Integer number
*
*/
constructor(num) {
this.num = num;
}
/**
* Marshal integer to buffer
*
* @returns {Uint8Array} Integer buffer
*
* @throws {Exception} When number is too large
*
*/
marshal() {
if (this.num > MAX) {
throw new Exception("Integer number cannot be greater than 0x3fff");
}
if (this.num <= integerValueCutter) {
return new Uint8Array([this.num & integerValueCutter]);
}
return new Uint8Array([
(this.num >> 7) | integerHasNextBit,
this.num & integerValueCutter,
]);
}
/**
* Parse the reader to build an Integer
*
* @param {reader.Reader} rd Data reader
*
*/
async unmarshal(rd) {
for (let i = 0; i < MAX_BYTES; i++) {
let r = await reader.readOne(rd);
this.num |= r[0] & integerValueCutter;
if ((integerHasNextBit & r[0]) == 0) {
return;
}
this.num <<= 7;
}
}
/**
* Return the value of the number
*
* @returns {number} The integer value
*
*/
value() {
return this.num;
}
}

60
commands/integer_test.js Normal file
View File

@ -0,0 +1,60 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 assert from "assert";
import * as reader from "../stream/reader.js";
import * as integer from "./integer.js";
describe("Integer", () => {
it("Integer 127", async () => {
let i = new integer.Integer(127),
marshalled = i.marshal();
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
assert.strictEqual(marshalled.length, 1);
r.feed(marshalled);
let i2 = new integer.Integer(0);
await i2.unmarshal(r);
assert.strictEqual(i.value(), i2.value());
});
it("Integer MAX", async () => {
let i = new integer.Integer(integer.MAX),
marshalled = i.marshal();
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
assert.strictEqual(marshalled.length, 2);
r.feed(marshalled);
let i2 = new integer.Integer(0);
await i2.unmarshal(r);
assert.strictEqual(i.value(), i2.value());
});
});

327
commands/presets.js Normal file
View File

@ -0,0 +1,327 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 Exception from "./exception.js";
/**
* Default preset item, contains data of a default preset
*
*/
const presetItem = {
title: "",
type: "",
host: "",
meta: {},
};
/**
* Verify Preset Item Meta
*
* @param {object} preset
*
*/
function verifyPresetItemMeta(preset) {
for (let i in preset.meta) {
if (typeof preset.meta[i] === "string") {
continue;
}
throw new Exception(
'The data type of meta field "' +
i +
'" was "' +
typeof preset.meta[i] +
'" instead of expected "string"',
);
}
}
/**
* Parse and verify the given preset, return a valid preset
*
* @param {object} item
*
* @throws {Exception} when invalid data is given
*
* @return {object}
*
*/
function parsePresetItem(item) {
let preset = {};
for (let i in presetItem) {
preset[i] = presetItem[i];
}
for (let i in presetItem) {
if (typeof presetItem[i] === typeof item[i]) {
preset[i] = item[i];
continue;
}
throw new Exception(
'Expecting the data type of "' +
i +
'" is "' +
typeof presetItem[i] +
'", given "' +
typeof item[i] +
'" instead',
);
}
verifyPresetItemMeta(preset.meta);
return preset;
}
/**
* Preset data
*
*/
export class Preset {
/**
* constructor
*
* @param {object} preset preset data
*
*/
constructor(preset) {
this.preset = parsePresetItem(preset);
}
/**
* Return the title of the preset
*
* @returns {string}
*
*/
title() {
return this.preset.title;
}
/**
* Return the type of the preset
*
* @returns {string}
*
*/
type() {
return this.preset.type;
}
/**
* Return the host of the preset
*
* @returns {string}
*
*/
host() {
return this.preset.host;
}
/**
* Return the given meta of current preset
*
* @param {string} name name of the meta data
*
* @throws {Exception} when invalid data is given
*
* @returns {string}
*
*/
meta(name) {
if (typeof this.preset.meta[name] !== "string") {
throw new Exception('Meta "' + name + '" was undefined');
}
return this.preset.meta[name];
}
/**
* Return the given meta of current preset, and if failed, return the given
* default value
*
* @param {string} name name of the meta data
* @param {string} defaultValue default value to be returned when the meta was
* not found
*
* @returns {string}
*
*/
metaDefault(name, defaultValue) {
try {
return this.meta(name);
} catch (e) {
return defaultValue;
}
}
/**
* Insert new meta item
*
* @param {string} name name of the meta data
* @param {string} data data of the meta data
*
* @throws {Exception} when invalid data is given
*
*/
insertMeta(name, data) {
if (typeof this.preset.meta[name] !== "undefined") {
throw new Exception('Meta "' + name + '" has already been defined');
}
this.preset.meta[name] = data;
}
/**
* Export all meta keys
*
* @returns {Array<string>} All meta keys
*
*/
metaKeys() {
let keys = [];
for (let k in this.preset.meta) {
keys.push(k);
}
return keys;
}
}
/**
* Returns an empty preset
*
* @returns {Preset}
*
*/
export function emptyPreset() {
return new Preset({
title: "Default",
type: "Default",
host: "",
meta: {},
});
}
/**
* Command Preset manager
*
*/
export class Presets {
/**
* constructor
*
* @param {Array<object>} presets Array of preset data
*
*/
constructor(presets) {
this.presets = [];
for (let i = 0; i < presets.length; i++) {
this.presets.push(new Preset(presets[i]));
}
}
/**
* Return all presets of a type
*
* @param {string} type type of the presets data
*
* @returns {Array<Preset>}
*
*/
fetch(type) {
let presets = [];
for (let i = 0; i < this.presets.length; i++) {
if (this.presets[i].type() !== type) {
continue;
}
presets.push(this.presets[i]);
}
return presets;
}
/**
* Return presets with matched type and meta data
*
* @param {string} type type of the presets data
* @param {string} metaName name of the meta data
* @param {string} metaVal value of the meta data
*
* @returns {Array<Preset>}
*
*/
meta(type, metaName, metaVal) {
let presets = [];
for (let i = 0; i < this.presets.length; i++) {
if (this.presets[i].type() !== type) {
continue;
}
try {
if (this.presets[i].meta(metaName) !== metaVal) {
continue;
}
} catch (e) {
if (!(e instanceof Exception)) {
throw e;
}
continue;
}
presets.push(this.presets[i]);
}
return presets;
}
/**
* Return presets with matched type and host
*
* @param {string} type type of the presets
* @param {string} host host of the presets
*
* @returns {Array<Preset>}
*
*/
hosts(type, host) {
let presets = [];
for (let i = 0; i < this.presets.length; i++) {
if (this.presets[i].type() !== type) {
continue;
}
if (this.presets[i].host() !== host) {
continue;
}
presets.push(this.presets[i]);
}
return presets;
}
}

1124
commands/ssh.js Normal file

File diff suppressed because it is too large Load Diff

72
commands/string.js Normal file
View File

@ -0,0 +1,72 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as reader from "../stream/reader.js";
import * as integer from "./integer.js";
export class String {
/**
* Read String from given reader
*
* @param {reader.Reader} rd Source reader
*
* @returns {String} readed string
*
*/
static async read(rd) {
let l = new integer.Integer(0);
await l.unmarshal(rd);
return new String(await reader.readN(rd, l.value()));
}
/**
* constructor
*
* @param {Uint8Array} str String data
*/
constructor(str) {
this.str = str;
}
/**
* Return the string
*
* @returns {Uint8Array} String data
*
*/
data() {
return this.str;
}
/**
* Return serialized String as array
*
* @returns {Uint8Array} serialized String
*
*/
buffer() {
let lBytes = new integer.Integer(this.str.length).marshal(),
buf = new Uint8Array(lBytes.length + this.str.length);
buf.set(lBytes, 0);
buf.set(this.str, lBytes.length);
return buf;
}
}

265
commands/string_test.js Normal file
View File

@ -0,0 +1,265 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 assert from "assert";
import * as reader from "../stream/reader.js";
import * as strings from "./string.js";
describe("String", () => {
it("String 1", async () => {
let s = new strings.String(new Uint8Array(["H", "E", "L", "L", "O"])),
sBuf = s.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
r.feed(sBuf);
let s2 = await strings.String.read(r);
assert.deepStrictEqual(s2.data(), s.data());
});
it("String 2", async () => {
let s = new strings.String(
new Uint8Array([
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
"H",
"E",
"L",
"L",
"O",
"W",
"O",
"R",
"L",
"D",
]),
),
sBuf = s.buffer();
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
r.feed(sBuf);
let s2 = await strings.String.read(r);
assert.deepStrictEqual(s2.data(), s.data());
});
});

648
commands/telnet.js Normal file
View File

@ -0,0 +1,648 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as header from "../stream/header.js";
import * as reader from "../stream/reader.js";
import * as stream from "../stream/stream.js";
import * as address from "./address.js";
import * as command from "./commands.js";
import * as common from "./common.js";
import * as controls from "./controls.js";
import * as event from "./events.js";
import Exception from "./exception.js";
import * as history from "./history.js";
import * as presets from "./presets.js";
const COMMAND_ID = 0x00;
const SERVER_INITIAL_ERROR_BAD_ADDRESS = 0x01;
const SERVER_REMOTE_BAND = 0x00;
const SERVER_DIAL_FAILED = 0x01;
const SERVER_DIAL_CONNECTED = 0x02;
const DEFAULT_PORT = 23;
const HostMaxSearchResults = 3;
class Telnet {
/**
* constructor
*
* @param {stream.Sender} sd Stream sender
* @param {object} config configuration
* @param {object} callbacks Event callbacks
*
*/
constructor(sd, config, callbacks) {
this.sender = sd;
this.config = config;
this.connected = false;
this.events = new event.Events(
[
"initialization.failed",
"initialized",
"connect.failed",
"connect.succeed",
"@inband",
"close",
"@completed",
],
callbacks,
);
}
/**
* Send intial request
*
* @param {stream.InitialSender} initialSender Initial stream request sender
*
*/
run(initialSender) {
let addr = new address.Address(
this.config.host.type,
this.config.host.address,
this.config.host.port,
),
addrBuf = addr.buffer();
let data = new Uint8Array(addrBuf.length);
data.set(addrBuf, 0);
initialSender.send(data);
}
/**
* Receive the initial stream request
*
* @param {header.InitialStream} streamInitialHeader Server respond on the
* initial stream request
*
*/
initialize(streamInitialHeader) {
if (!streamInitialHeader.success()) {
this.events.fire("initialization.failed", streamInitialHeader);
return;
}
this.events.fire("initialized", streamInitialHeader);
}
/**
* Tick the command
*
* @param {header.Stream} streamHeader Stream data header
* @param {reader.Limited} rd Data reader
*
* @returns {any} The result of the ticking
*
* @throws {Exception} When the stream header type is unknown
*
*/
tick(streamHeader, rd) {
switch (streamHeader.marker()) {
case SERVER_DIAL_CONNECTED:
if (!this.connected) {
this.connected = true;
return this.events.fire("connect.succeed", rd, this);
}
break;
case SERVER_DIAL_FAILED:
if (!this.connected) {
return this.events.fire("connect.failed", rd);
}
break;
case SERVER_REMOTE_BAND:
if (this.connected) {
return this.events.fire("inband", rd);
}
break;
}
throw new Exception("Unknown stream header marker");
}
/**
* Send close signal to remote
*
*/
sendClose() {
return this.sender.close();
}
/**
* Send data to remote
*
* @param {Uint8Array} data
*
*/
sendData(data) {
return this.sender.sendData(0x00, data);
}
/**
* Close the command
*
*/
close() {
this.sendClose();
return this.events.fire("close");
}
/**
* Tear down the command completely
*
*/
completed() {
return this.events.fire("completed");
}
}
const initialFieldDef = {
Host: {
name: "Host",
description:
"Looking for server to connect&quest; Checkout " +
'<a href="http://www.telnet.org/htm/places.htm" target="blank">' +
"telnet.org</a> for public servers.",
type: "text",
value: "",
example: "telnet.vaguly.com:23",
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
if (d.length <= 0) {
throw new Error("Hostname must be specified");
}
let addr = common.splitHostPort(d, DEFAULT_PORT);
if (addr.addr.length <= 0) {
throw new Error("Cannot be empty");
}
if (addr.addr.length > address.MAX_ADDR_LEN) {
throw new Error(
"Can no longer than " + address.MAX_ADDR_LEN + " bytes",
);
}
if (addr.port <= 0) {
throw new Error("Port must be specified");
}
return "Look like " + addr.type + " address";
},
},
Encoding: {
name: "Encoding",
description: "The character encoding of the server",
type: "select",
value: "utf-8",
example: common.charsetPresets.join(","),
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
for (let i in common.charsetPresets) {
if (common.charsetPresets[i] !== d) {
continue;
}
return "";
}
throw new Error('The character encoding "' + d + '" is not supported');
},
},
};
class Wizard {
/**
* constructor
*
* @param {command.Info} info
* @param {presets.Preset} preset
* @param {object} session
* @param {Array<string>} keptSessions
* @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs
* @param {controls.Controls} controls
* @param {history.History} history
*
*/
constructor(
info,
preset,
session,
keptSessions,
streams,
subs,
controls,
history,
) {
this.info = info;
this.preset = preset;
this.hasStarted = false;
this.streams = streams;
this.session = session;
this.keptSessions = keptSessions;
this.step = subs;
this.controls = controls.get("Telnet");
this.history = history;
}
run() {
this.step.resolve(this.stepInitialPrompt());
}
started() {
return this.hasStarted;
}
control() {
return this.controls;
}
close() {
this.step.resolve(
this.stepErrorDone(
"Action cancelled",
"Action has been cancelled without reach any success",
),
);
}
stepErrorDone(title, message) {
return command.done(false, null, title, message);
}
stepSuccessfulDone(data) {
return command.done(
true,
data,
"Success!",
"We have connected to the remote",
);
}
stepWaitForAcceptWait() {
return command.wait(
"Requesting",
"Waiting for the request to be accepted by the backend",
);
}
stepWaitForEstablishWait(host) {
return command.wait(
"Connecting to " + host,
"Establishing connection with the remote host, may take a while",
);
}
/**
*
* @param {stream.Sender} sender
* @param {object} configInput
* @param {object} sessionData
*
*/
buildCommand(sender, configInput, sessionData) {
let self = this;
let parsedConfig = {
host: address.parseHostPort(configInput.host, DEFAULT_PORT),
charset: configInput.charset,
};
// Copy the keptSessions from the record so it will not be overwritten here
let keptSessions = self.keptSessions ? [].concat(...self.keptSessions) : [];
return new Telnet(sender, parsedConfig, {
"initialization.failed"(streamInitialHeader) {
switch (streamInitialHeader.data()) {
case SERVER_INITIAL_ERROR_BAD_ADDRESS:
self.step.resolve(
self.stepErrorDone("Request rejected", "Invalid address"),
);
return;
}
self.step.resolve(
self.stepErrorDone(
"Request rejected",
"Unknown error code: " + streamInitialHeader.data(),
),
);
},
initialized(streamInitialHeader) {
self.step.resolve(self.stepWaitForEstablishWait(configInput.host));
},
"connect.succeed"(rd, commandHandler) {
self.step.resolve(
self.stepSuccessfulDone(
new command.Result(
configInput.host,
self.info,
self.controls.build({
charset: parsedConfig.charset,
send(data) {
return commandHandler.sendData(data);
},
close() {
return commandHandler.sendClose();
},
events: commandHandler.events,
}),
self.controls.ui(),
),
),
);
self.history.save(
self.info.name() + ":" + configInput.host,
configInput.host,
new Date(),
self.info,
configInput,
sessionData,
keptSessions,
);
},
async "connect.failed"(rd) {
let readed = await reader.readCompletely(rd),
message = new TextDecoder("utf-8").decode(readed.buffer);
self.step.resolve(self.stepErrorDone("Connection failed", message));
},
"@inband"(rd) {},
close() {},
"@completed"() {},
});
}
stepInitialPrompt() {
const self = this;
return command.prompt(
"Telnet",
"Teletype Network",
"Connect",
(r) => {
self.hasStarted = true;
self.streams.request(COMMAND_ID, (sd) => {
return self.buildCommand(
sd,
{
host: r.host,
charset: r.encoding,
},
self.session,
);
});
self.step.resolve(self.stepWaitForAcceptWait());
},
() => {},
command.fieldsWithPreset(
initialFieldDef,
[
{
name: "Host",
suggestions(input) {
const hosts = self.history.search(
"Telnet",
"host",
input,
HostMaxSearchResults,
);
let sugg = [];
for (let i = 0; i < hosts.length; i++) {
sugg.push({
title: hosts[i].title,
value: hosts[i].data.host,
meta: {
Encoding: hosts[i].data.charset,
},
});
}
return sugg;
},
},
{ name: "Encoding" },
],
self.preset,
(r) => {},
),
);
}
}
class Executor extends Wizard {
/**
* constructor
*
* @param {command.Info} info
* @param {object} config
* @param {object} session
* @param {Array<string>} keptSessions
* @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs
* @param {controls.Controls} controls
* @param {history.History} history
*
*/
constructor(
info,
config,
session,
keptSessions,
streams,
subs,
controls,
history,
) {
super(
info,
presets.emptyPreset(),
session,
keptSessions,
streams,
subs,
controls,
history,
);
this.config = config;
}
stepInitialPrompt() {
const self = this;
self.hasStarted = true;
self.streams.request(COMMAND_ID, (sd) => {
return self.buildCommand(
sd,
{
host: self.config.host,
charset: self.config.charset ? self.config.charset : "utf-8",
},
self.session,
);
});
return self.stepWaitForAcceptWait();
}
}
export class Command {
constructor() {}
id() {
return COMMAND_ID;
}
name() {
return "Telnet";
}
description() {
return "Teletype Network";
}
color() {
return "#6ac";
}
wizard(
info,
preset,
session,
keptSessions,
streams,
subs,
controls,
history,
) {
return new Wizard(
info,
preset,
session,
keptSessions,
streams,
subs,
controls,
history,
);
}
execute(
info,
config,
session,
keptSessions,
streams,
subs,
controls,
history,
) {
return new Executor(
info,
config,
session,
keptSessions,
streams,
subs,
controls,
history,
);
}
launch(info, launcher, streams, subs, controls, history) {
const d = launcher.split("|", 2);
if (d.length <= 0) {
throw new Exception('Given launcher "' + launcher + '" was invalid');
}
try {
initialFieldDef["Host"].verify(d[0]);
} catch (e) {
throw new Exception(
'Given launcher "' + launcher + '" was invalid: ' + e,
);
}
let charset = "utf-8";
if (d.length > 1) {
// TODO: Remove this check after depreciation period.
try {
initialFieldDef["Encoding"].verify(d[1]);
charset = d[1];
} catch (e) {
throw new Exception(
'Given launcher "' + launcher + '" was invalid: ' + e,
);
}
}
return this.execute(
info,
{
host: d[0],
charset: charset,
},
null,
null,
streams,
subs,
controls,
history,
);
}
launcher(config) {
return config.host + "|" + (config.charset ? config.charset : "utf-8");
}
represet(preset) {
const host = preset.host();
if (host.length > 0) {
preset.insertMeta("Host", host);
}
return preset;
}
}

694
common.css Normal file
View File

@ -0,0 +1,694 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "UTF-8";
@import "~normalize.css";
html,
body {
height: 100%;
}
* {
margin: 0;
padding: 0;
}
p {
overflow: hidden;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
padding: 0;
}
a {
outline: 0;
}
body {
line-height: 1.5;
}
body {
background: #444;
font-family: Arial, Helvetica, sans-serif;
font-size: 1em;
position: relative;
}
/* Tabs */
.tab1 {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-items: center;
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
height: 100%;
}
.tab1 > li {
padding: 0 15px;
color: #999;
white-space: nowrap;
word-wrap: none;
text-overflow: ellipsis;
overflow: hidden;
flex: initial;
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
}
.tab1 > li.active {
background: #444;
}
.tab1.tab1-list {
flex-direction: column;
flex-wrap: wrap;
}
.tab1.tab1-list > li {
flex: 0 0;
margin: 0;
padding: 0;
width: 100%;
box-sizing: border-box;
}
.tab2 {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-items: center;
list-style: none;
list-style-position: inside;
margin: 0;
height: 100%;
color: #aaa;
border-bottom: 1px solid #a56;
background: #333;
padding: 0 10px;
position: relative;
}
.tab2::before {
content: " ";
display: block;
position: absolute;
width: 100%;
height: 1px;
left: 0;
right: 0;
bottom: 0;
box-shadow: 0 -3px 3px #0003;
}
.tab2 > li {
flex: auto;
cursor: pointer;
border-color: transparent;
border-width: 1px 1px 0 1px;
border-style: solid;
padding: 7px 10px;
text-align: center;
position: relative;
z-index: 1;
}
.tab2 > li.active {
color: #fff;
background: #644;
margin-bottom: -1px;
border-color: #a56;
border-style: solid;
box-shadow: 0 -2px 2px #0002;
}
/* List */
.lst-nostyle {
list-style: none;
list-style-position: inside;
}
.hlst {
}
.hlst > li {
float: left;
}
.lst1 {
margin: 0;
padding: 0;
width: 100%;
}
.lst1 > li {
border-bottom: 1px solid #555;
}
.lst1 > li:last-child {
border-bottom: none;
}
.lst1 > li .lst-wrap {
padding: 10px;
}
.hlst.lstcl1 {
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
width: 100%;
overflow: auto;
}
.hlst.lstcl1 > li {
width: 33%;
white-space: nowrap;
}
.hlst.lstcl1 > li .lst-wrap {
padding: 10px;
margin: 10px;
background: #333;
text-overflow: ellipsis;
overflow: hidden;
box-shadow: 2px 2px 0 0 #222;
}
.hlst.lstcl1 > li .lst-wrap:hover {
background: #3a3a3a;
box-shadow: 2px 2px 0 0 #222;
}
.hlst.lstcl1 > li .lst-wrap:active {
background: #333;
}
.hlst.lstcl2 {
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
width: 100%;
overflow: auto;
}
.hlst.lstcl2 > li {
width: 33%;
white-space: nowrap;
}
.hlst.lstcl2 > li .lst-wrap {
padding: 10px;
margin: 5px;
background: #333;
text-overflow: ellipsis;
overflow: hidden;
}
/* Icon */
.icon {
line-height: 1;
overflow: visible;
}
.icon.icon-close1 {
margin-top: -2.5px;
font-size: 26px;
line-height: 0;
}
.icon.icon-close1::before {
content: "\00d7";
font-weight: bold;
display: block;
margin: 0;
padding: 6px;
}
.icon.icon-plus1 {
color: #fff;
background: #a56;
}
.icon.icon-plus1::before {
content: "+";
font-weight: bold;
}
.icon.icon-more1 {
color: #fff;
background: #222;
}
.icon.icon-more1::before {
content: "\2261";
font-weight: bold;
}
.icon.icon-warning1 {
font-size: 20px;
}
.icon.icon-warning1::after {
content: "!";
font-weight: bold;
background: #e11;
padding: 3px 13px;
}
.icon.icon-point1 {
position: relative;
text-shadow: 0 0 3px #fff;
}
.icon.icon-point1::after {
content: "\25CF";
}
.icon.icon-keyboardkey1 {
background: #fff;
color: #999;
padding: 4px 6px;
display: inline-block;
border-radius: 3px;
box-shadow: 1px 1px 0 2px #0003;
}
.icon.icon-iconed-bottom1 {
padding: 4px 6px;
display: inline-block;
border-radius: 3px;
text-align: center;
}
.icon.icon-iconed-bottom1 > i {
font-style: normal;
display: block;
margin: 3px;
font-size: 2em;
font-weight: normal;
}
/* Windows */
.window {
position: absolute;
}
.window.window1 {
background: #a56;
box-shadow: 0 0 5px #0006;
color: #fff;
font-size: 1em;
display: none;
}
.window.window1.display {
display: block;
}
.window.window1::before {
top: -5px;
position: absolute;
display: block;
content: " ";
width: 10px;
height: 10px;
background: #a56;
transform: rotate(45deg);
}
.window.window1 .window-frame {
width: 100%;
overflow: auto;
position: relative;
}
.window.window1 .window-title {
font-size: 0.9em;
font-weight: bold;
text-transform: uppercase;
color: #e9a;
}
.window.window1 .window-close {
position: absolute;
top: 10px;
right: 10px;
border-color: #844;
cursor: pointer;
}
.window.window1 .window-close::after {
border-color: #844;
}
/* Form1 */
.form1 {
}
.form1 > fieldset,
.form1 > fieldset * {
padding: 0;
margin: 0;
color: #fff;
outline: none;
border: 0;
font-size: 1em;
}
.form1 > fieldset .field {
color: #ccc;
display: block;
width: 100%;
overflow: auto;
}
.form1 > fieldset .field.horizontal {
width: auto;
float: left;
margin-right: 10px;
}
.form1 > fieldset .items {
width: 100%;
overflow: auto;
}
.form1 > fieldset .field.horizontal.item {
margin-top: 3px;
margin-bottom: 3px;
}
.form1 > fieldset .field.horizontal:last-child {
margin-right: 0;
}
.form1 > fieldset .field,
.form1 > fieldset .field input,
.form1 > fieldset .field select,
.form1 > fieldset .field textarea,
.form1 > fieldset .field button {
vertical-align: middle;
font-size: 1.05em;
}
.form1 > fieldset .field {
font-size: 0.95em;
margin-bottom: 10px;
}
.form1 > fieldset .field > .textinfo {
color: #fff;
}
.form1 > fieldset .field > .textinfo > .info {
padding: 10px;
margin: 10px 0;
font-size: 1.1em;
background: #292929;
border: 1px solid #444;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.form1 > fieldset .field:last-child {
margin-bottom: 0;
}
.form1 > fieldset .field:last-child {
margin-bottom: 0;
}
.form1 > fieldset .field > input,
.form1 > fieldset .field > select,
.form1 > fieldset .field > textarea,
.form1 > fieldset .field > button {
box-sizing: border-box;
resize: none;
}
.form1 > fieldset .field > input::placeholder {
color: #666;
}
.form1 > fieldset .field > input:focus::placeholder {
color: #444;
}
.form1 > fieldset .field > input[type="text"],
.form1 > fieldset .field > input[type="file"],
.form1 > fieldset .field > input[type="email"],
.form1 > fieldset .field > input[type="number"],
.form1 > fieldset .field > input[type="search"],
.form1 > fieldset .field > input[type="tel"],
.form1 > fieldset .field > input[type="url"],
.form1 > fieldset .field > input[type="password"],
.form1 > fieldset .field > select,
.form1 > fieldset .field > textarea {
width: 100%;
padding: 10px;
border: 0;
background: #2e2e2e;
margin-top: 5px;
border-bottom: 2px solid #3e3e3e;
}
.form1 > fieldset .field > textarea {
min-height: 120px;
}
.form1 > fieldset .field.error > .error {
margin-top: 5px;
color: #f55;
}
.form1 > fieldset .field > .message {
margin-top: 5px;
color: #999;
}
.form1 > fieldset .field > .message * {
color: #999;
}
.form1 > fieldset .field > .message > p {
margin-bottom: 5px;
}
.form1 > fieldset .field > .message > a {
color: #e9a;
}
.form1 > fieldset .field.highlight > input[type="text"],
.form1 > fieldset .field.highlight > input[type="file"],
.form1 > fieldset .field.highlight > input[type="email"],
.form1 > fieldset .field.highlight > input[type="number"],
.form1 > fieldset .field.highlight > input[type="search"],
.form1 > fieldset .field.highlight > input[type="tel"],
.form1 > fieldset .field.highlight > input[type="url"],
.form1 > fieldset .field.highlight > input[type="password"],
.form1 > fieldset .field.highlight > select,
.form1 > fieldset .field.highlight > textarea {
background: #666;
border-bottom: 2px solid #ccc;
}
.form1 > fieldset .field.error > input[type="text"],
.form1 > fieldset .field.error > input[type="file"],
.form1 > fieldset .field.error > input[type="email"],
.form1 > fieldset .field.error > input[type="number"],
.form1 > fieldset .field.error > input[type="search"],
.form1 > fieldset .field.error > input[type="tel"],
.form1 > fieldset .field.error > input[type="url"],
.form1 > fieldset .field.error > input[type="password"],
.form1 > fieldset .field.error > select,
.form1 > fieldset .field.error > textarea {
background: #483535;
border-bottom: 2px solid #a83333;
}
.form1 > fieldset .field > input:disabled,
.form1 > fieldset .field > select:disabled,
.form1 > fieldset .field > textarea:disabled,
.form1 > fieldset .field > button:disabled {
opacity: 0.35;
}
.form1 > fieldset .field > input:disabled:active,
.form1 > fieldset .field > select:disabled:active,
.form1 > fieldset .field > textarea:disabled:active,
.form1 > fieldset .field > button:disabled:active {
opacity: 0.5;
}
.form1 > fieldset .field > input[type="checkbox"],
.form1 > fieldset .field > input[type="radio"] {
background: #2e2e2e;
margin: 1px 3px 1px 1px;
}
.form1 > fieldset .field > input[type="checkbox"]:active,
.form1 > fieldset .field > input[type="radio"]:active,
.form1 > fieldset .field > input[type="checkbox"]:focus,
.form1 > fieldset .field > input[type="radio"]:focus {
outline: 1px solid #e9a;
}
.form1 > fieldset .field > input[type="text"]:focus,
.form1 > fieldset .field > input[type="email"]:focus,
.form1 > fieldset .field > input[type="number"]:focus,
.form1 > fieldset .field > input[type="search"]:focus,
.form1 > fieldset .field > input[type="tel"]:focus,
.form1 > fieldset .field > input[type="url"]:focus,
.form1 > fieldset .field > input[type="password"]:focus,
.form1 > fieldset .field > select:focus,
.form1 > fieldset .field > textarea:focus {
background: #222;
border-bottom: 2px solid #e9a;
}
.form1 > fieldset .field > button {
padding: 8px 13px;
font-weight: normal;
background: #c78;
border-color: #c78;
border-width: 2px;
border-style: solid;
}
.form1 > fieldset .field > button:focus,
.form1 > fieldset .field > button:hover {
border-color: #a56;
background: #c78;
}
.form1 > fieldset .field > button:active {
background: #a56;
border-color: #a56;
}
.form1 > fieldset .field > button.secondary {
float: right;
background: transparent;
color: #eee;
border-color: #eee;
}
.form1 > fieldset .field > button.secondary:focus,
.form1 > fieldset .field > button.secondary:hover {
border-color: #ddd;
color: #ddd;
}
.form1 > fieldset .field > button.secondary:active {
border-color: #666;
color: #666;
}
.form1 > fieldset .field > ul.input-suggestions {
background: #262626;
box-shadow: 0 0 3px #0006;
border: 1px solid #666;
position: relative;
margin: 3px;
}
.form1 > fieldset .field > ul.input-suggestions::before,
.form1 > fieldset .field > ul.input-suggestions::after {
top: -5px;
left: 5px;
position: absolute;
z-index: 1;
display: block;
content: " ";
width: 10px;
height: 10px;
background: #262626;
transform: rotate(45deg);
}
.form1 > fieldset .field > ul.input-suggestions::after {
top: -6px;
z-index: 0;
background: #666;
}
.form1 > fieldset .field > ul.input-suggestions > li {
position: relative;
z-index: 2;
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #222;
}
.form1 > fieldset .field > ul.input-suggestions > li:hover,
.form1 > fieldset .field > ul.input-suggestions > li.current {
background: #555;
border-bottom: 1px solid #555;
}
.form1 > fieldset .field > ul.input-suggestions > li:first-child:hover::before,
.form1
> fieldset
.field
> ul.input-suggestions
> li.current:first-child::before {
top: -6px;
position: absolute;
z-index: 0;
left: 5px;
display: block;
content: " ";
width: 10px;
height: 10px;
background: #555;
transform: rotate(45deg);
}
.form1 > fieldset .field > ul.input-suggestions > li:last-child {
border-bottom: none;
}
.form1 > fieldset .field > ul.input-suggestions > li > .sugt-title {
color: #fff;
font-weight: bold;
margin: 0 5px;
}
.form1 > fieldset .field > ul.input-suggestions > li > .sugt-value {
color: #fdd;
font-size: 0.9em;
margin: 0 5px;
}

152
control/ssh.js Normal file
View File

@ -0,0 +1,152 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as iconv from "iconv-lite";
import * as color from "../commands/color.js";
import * as common from "../commands/common.js";
import * as reader from "../stream/reader.js";
import * as subscribe from "../stream/subscribe.js";
class Control {
constructor(data, color) {
this.colorM = color;
this.colors = this.colorM.get();
this.charset = data.charset;
this.charsetDecoder = (d) => {
return iconv.decode(d, this.charset);
};
this.charsetEncoder = (dStr) => {
return iconv.encode(dStr, this.charset);
};
this.enable = false;
this.sender = data.send;
this.closer = data.close;
this.resizer = data.resize;
this.subs = new subscribe.Subscribe();
let self = this;
data.events.place("stdout", async (rd) => {
try {
self.subs.resolve(self.charsetDecoder(await reader.readCompletely(rd)));
} catch (e) {
// Do nothing
}
});
data.events.place("stderr", async (rd) => {
try {
self.subs.resolve(self.charsetDecoder(await reader.readCompletely(rd)));
} catch (e) {
// Do nothing
}
});
data.events.place("completed", () => {
self.closed = true;
self.colorM.forget(self.colors.color);
self.subs.reject("Remote connection has been terminated");
});
}
echo() {
return false;
}
resize(dim) {
if (this.closed) {
return;
}
this.resizer(dim.rows, dim.cols);
}
enabled() {
this.enable = true;
}
disabled() {
this.enable = false;
}
retap(isOn) {}
receive() {
return this.subs.subscribe();
}
send(data) {
if (this.closed) {
return;
}
return this.sender(this.charsetEncoder(data));
}
sendBinary(data) {
if (this.closed) {
return;
}
return this.sender(common.strToBinary(data));
}
color() {
return this.colors.dark;
}
activeColor() {
return this.colors.color;
}
close() {
if (this.closer === null) {
return;
}
let cc = this.closer;
this.closer = null;
return cc();
}
}
export class SSH {
/**
* constructor
*
* @param {color.Color} c
*/
constructor(c) {
this.color = c;
}
type() {
return "SSH";
}
ui() {
return "Console";
}
build(data) {
return new Control(data, this.color);
}
}

513
control/telnet.js Normal file
View File

@ -0,0 +1,513 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as iconv from "iconv-lite";
import * as color from "../commands/color.js";
import * as common from "../commands/common.js";
import Exception from "../commands/exception.js";
import * as reader from "../stream/reader.js";
import * as subscribe from "../stream/subscribe.js";
// const maxReadBufSize = 1024;
const cmdSE = 240;
// const cmdNOP = 241;
// const cmdDataMark = 242;
// const cmdBreak = 243;
// const cmdInterrputProcess = 244;
// const cmdAbortOutput = 245;
// const cmdAreYouThere = 246;
// const cmdEraseCharacter = 247;
// const cmdEraseLine = 248;
const cmdGoAhead = 249;
const cmdSB = 250;
const cmdWill = 251;
const cmdWont = 252;
const cmdDo = 253;
const cmdDont = 254;
const cmdIAC = 255;
const optEcho = 1;
const optSuppressGoAhead = 3;
const optTerminalType = 24;
const optNAWS = 31;
const optTerminalTypeIs = 0;
const optTerminalTypeSend = 1;
const unknownTermTypeSendData = new Uint8Array([
optTerminalTypeIs,
88,
84,
69,
82,
77,
]);
// Most of code of this class is directly from
// https://github.com/ziutek/telnet/blob/master/conn.go#L122
// Thank you!
class Parser {
constructor(sender, flusher, callbacks) {
this.sender = sender;
this.flusher = flusher;
this.callbacks = callbacks;
this.reader = new reader.Multiple(() => {});
this.options = {
echoEnabled: false,
suppressGoAhead: false,
nawsAccpeted: false,
};
this.current = 0;
}
sendNego(cmd, option) {
return this.sender(new Uint8Array([cmdIAC, cmd, option]));
}
sendDeny(cmd, o) {
switch (cmd) {
case cmdDo:
return this.sendNego(cmdWont, o);
case (cmdWill, cmdWont):
return this.sendNego(cmdDont, o);
}
}
sendWillSubNego(willCmd, data, option) {
let b = new Uint8Array(6 + data.length + 2);
b.set([cmdIAC, willCmd, option, cmdIAC, cmdSB, option], 0);
b.set(data, 6);
b.set([cmdIAC, cmdSE], data.length + 6);
return this.sender(b);
}
sendSubNego(data, option) {
let b = new Uint8Array(3 + data.length + 2);
b.set([cmdIAC, cmdSB, option], 0);
b.set(data, 3);
b.set([cmdIAC, cmdSE], data.length + 3);
return this.sender(b);
}
async handleTermTypeSubNego(rd) {
let action = await reader.readOne(rd);
if (action[0] !== optTerminalTypeSend) {
return null;
}
let self = this;
return () => {
self.sendSubNego(unknownTermTypeSendData, optTerminalType);
};
}
async handleSubNego(rd) {
let endExec = null;
for (;;) {
let d = await reader.readOne(rd);
switch (d[0]) {
case optTerminalType:
endExec = await this.handleTermTypeSubNego(rd);
continue;
case cmdIAC:
break;
default:
continue;
}
let e = await reader.readOne(rd);
if (e[0] !== cmdSE) {
continue;
}
if (endExec !== null) {
endExec();
}
return;
}
}
handleOption(cmd, option, oldVal, newVal) {
switch (cmd) {
case cmdWill:
if (!oldVal) {
this.sendNego(cmdDo, option);
}
newVal(true, cmdWill);
return;
case cmdWont:
if (oldVal) {
this.sendNego(cmdDont, option);
}
newVal(false, cmdWont);
return;
case cmdDo:
if (!oldVal) {
this.sendNego(cmdWill, option);
}
newVal(true, cmdDo);
return;
case cmdDont:
if (oldVal) {
this.sendNego(cmdWont, option);
}
newVal(false, cmdDont);
return;
}
}
async handleCmd(rd) {
let d = await reader.readOne(rd);
switch (d[0]) {
case cmdWill:
case cmdWont:
case cmdDo:
case cmdDont:
break;
case cmdIAC:
this.flusher(d);
return;
case cmdGoAhead:
return;
case cmdSB:
await this.handleSubNego(rd);
return;
default:
throw new Exception("Unknown command");
}
let o = await reader.readOne(rd);
switch (o[0]) {
case optEcho:
return this.handleOption(
d[0],
o[0],
this.options.echoEnabled,
(d, action) => {
this.options.echoEnabled = d;
switch (action) {
case cmdWill:
case cmdDont:
this.callbacks.setEcho(false);
break;
case cmdWont:
case cmdDo:
this.callbacks.setEcho(true);
break;
}
},
);
case optSuppressGoAhead:
return this.handleOption(
d[0],
o[0],
this.options.suppressGoAhead,
(d, _action) => {
this.options.suppressGoAhead = d;
},
);
case optNAWS:
// Window resize allowed?
if (d[0] !== cmdDo) {
this.sendDeny(d[0], o[0]);
return;
}
{
let dim = this.callbacks.getWindowDim(),
dimData = new DataView(new ArrayBuffer(4));
dimData.setUint16(0, dim.cols);
dimData.setUint16(2, dim.rows);
let dimBytes = new Uint8Array(dimData.buffer);
if (this.options.nawsAccpeted) {
this.sendSubNego(dimBytes, optNAWS);
return;
}
this.options.nawsAccpeted = true;
this.sendWillSubNego(cmdWill, dimBytes, optNAWS);
}
return;
case optTerminalType:
if (d[0] !== cmdDo) {
this.sendDeny(d[0], o[0]);
return;
}
this.sendNego(cmdWill, o[0]);
return;
}
this.sendDeny(d[0], o[0]);
}
requestWindowResize() {
this.options.nawsAccpeted = true;
this.sendNego(cmdWill, optNAWS);
}
async run() {
try {
for (;;) {
let d = await reader.readUntil(this.reader, cmdIAC);
if (!d.found) {
this.flusher(d.data);
continue;
}
if (d.data.length > 1) {
this.flusher(d.data.slice(0, d.data.length - 1));
}
await this.handleCmd(this.reader);
}
} catch (e) {
// Do nothing
}
}
feed(rd, cb) {
this.reader.feed(rd, cb);
}
close() {
this.reader.close();
}
}
class Control {
constructor(data, color) {
this.colorM = color;
this.colors = this.colorM.get();
this.charset = data.charset;
this.charsetDecoder = (d) => {
return iconv.decode(d, this.charset);
};
this.charsetEncoder = (dStr) => {
return iconv.encode(dStr, this.charset);
};
this.sender = data.send;
this.closer = data.close;
this.closed = false;
this.localEchoEnabled = true;
this.subs = new subscribe.Subscribe();
this.enable = false;
this.windowDim = {
cols: 65535,
rows: 65535,
};
let self = this;
this.parser = new Parser(
this.sender,
(d) => {
self.subs.resolve(this.charsetDecoder(d));
},
{
setEcho(newVal) {
self.localEchoEnabled = newVal;
},
getWindowDim() {
return self.windowDim;
},
},
);
let runWait = this.parser.run();
data.events.place("inband", (rd) => {
return new Promise((resolve, _reject) => {
self.parser.feed(rd, () => {
resolve(true);
});
});
});
data.events.place("completed", async () => {
self.parser.close();
self.closed = true;
self.colorM.forget(self.colors.color);
await runWait;
self.subs.reject("Remote connection has been terminated");
});
}
echo() {
return this.localEchoEnabled;
}
resize(dim) {
if (this.closed) {
return;
}
this.windowDim.cols = dim.cols;
this.windowDim.rows = dim.rows;
this.parser.requestWindowResize();
}
enabled() {
this.enable = true;
}
disabled() {
this.enable = false;
}
retap(_isOn) {}
receive() {
return this.subs.subscribe();
}
searchNextIAC(start, data) {
for (let i = start; i < data.length; i++) {
if (data[i] !== cmdIAC) {
continue;
}
return i;
}
return -1;
}
sendSeg(enc) {
let currentLen = 0;
while (currentLen < enc.length) {
const iacPos = this.searchNextIAC(currentLen, enc);
if (iacPos < 0) {
this.sender(enc.slice(currentLen, enc.length));
return;
}
this.sender(enc.slice(currentLen, iacPos + 1));
this.sender(enc.slice(iacPos, iacPos + 1));
currentLen = iacPos + 1;
}
}
send(data) {
if (this.closed) {
return;
}
this.sendSeg(this.charsetEncoder(data));
}
sendBinary(data) {
if (this.closed) {
return;
}
return this.sendSeg(common.strToBinary(data));
}
color() {
return this.colors.dark;
}
activeColor() {
return this.colors.color;
}
close() {
if (this.closer === null) {
return;
}
let cc = this.closer;
this.closer = null;
return cc();
}
}
export class Telnet {
/**
* constructor
*
* @param {color.Color} c
*/
constructor(c) {
this.color = c;
}
type() {
return "Telnet";
}
ui() {
return "Console";
}
build(data) {
return new Control(data, this.color);
}
}

118
crypto.js Normal file
View File

@ -0,0 +1,118 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
/**
* Generate HMAC 512 of given data
*
* @param {Uint8Array} secret Secret key
* @param {Uint8Array} data Data to be HMAC'ed
*/
export async function hmac512(secret, data) {
const key = await window.crypto.subtle.importKey(
"raw",
secret,
{
name: "HMAC",
hash: { name: "SHA-512" },
},
false,
["sign", "verify"],
);
return window.crypto.subtle.sign(key.algorithm, key, data);
}
export const GCMNonceSize = 12;
export const GCMKeyBitLen = 128;
/**
* Build AES GCM Encryption/Decryption key
*
* @param {Uint8Array} keyData Key data
*/
export function buildGCMKey(keyData) {
return window.crypto.subtle.importKey(
"raw",
keyData,
{
name: "AES-GCM",
length: GCMKeyBitLen,
},
false,
["encrypt", "decrypt"],
);
}
/**
* Encrypt data
*
* @param {CryptoKey} key Key
* @param {Uint8Array} iv Nonce
* @param {Uint8Array} plaintext Data to be encrypted
*/
export function encryptGCM(key, iv, plaintext) {
return window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv, tagLength: GCMKeyBitLen },
key,
plaintext,
);
}
/**
* Decrypt data
*
* @param {CryptoKey} key Key
* @param {Uint8Array} iv Nonce
* @param {Uint8Array} cipherText Data to be decrypted
*/
export function decryptGCM(key, iv, cipherText) {
return window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv, tagLength: GCMKeyBitLen },
key,
cipherText,
);
}
/**
* generate Random nonce
*
*/
export function generateNonce() {
return window.crypto.getRandomValues(new Uint8Array(GCMNonceSize));
}
/**
* Increase nonce by one
*
* @param {Uint8Array} nonce Nonce data
*
* @returns {Uint8Array} New nonce
*
*/
export function increaseNonce(nonce) {
for (let i = nonce.length; i > 0; i--) {
nonce[i - 1]++;
if (nonce[i - 1] <= 0) {
continue;
}
break;
}
return nonce;
}

View File

@ -15,21 +15,21 @@ services:
- /etc/localtime:/etc/localtime:ro
# KEYCLOAK GATEKEEPER
gatekeeper:
container_name: gatekeeper
restart: always
build:
context: ./keycloak-gatekeeper/.
ports:
- 8880:8880
depends_on:
- sshwifty
command:
- "--discovery-url=https://<keycloak-dns>/auth/realms/<realm>/.well-known/openid-configuration"
- "--client-id=sshwifty"
- "--client-secret="
- "--listen=0.0.0.0:8880"
- "--upstream-url=http://sshwifty:8182"
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# gatekeeper:
# container_name: gatekeeper
# restart: always
# build:
# context: ./keycloak-gatekeeper/.
# ports:
# - 8880:8880
# depends_on:
# - sshwifty
# command:
# - "--discovery-url=https://<keycloak-dns>/auth/realms/<realm>/.well-known/openid-configuration"
# - "--client-id=sshwifty"
# - "--client-secret="
# - "--listen=0.0.0.0:8880"
# - "--upstream-url=http://sshwifty:8182"
# volumes:
# - /etc/timezone:/etc/timezone:ro
# - /etc/localtime:/etc/localtime:ro

37
error.html Normal file
View File

@ -0,0 +1,37 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Error</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body class="app-error">
<div id="app-loading">
<div id="app-loading-frame">
<div id="app-loading-error">&times;</div>
<h1 id="app-loading-title" class="error">
Server was unable to complete the request
</h1>
</div>
</div>
</body>
</html>

2
go.mod
View File

@ -21,7 +21,7 @@ go 1.21.5
require (
github.com/gorilla/websocket v1.5.1
golang.org/x/crypto v0.16.0
golang.org/x/crypto v0.17.0
golang.org/x/net v0.19.0
)

2
go.sum
View File

@ -47,6 +47,8 @@ golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=

54
history.js Normal file
View File

@ -0,0 +1,54 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
export class Records {
/**
* constructor
*
* @param {array} data Data space
*/
constructor(data) {
this.data = data;
}
/**
* Insert new item into the history records
*
* @param {number} newData New value
*/
update(newData) {
this.data.shift();
this.data.push({ data: newData, class: "" });
}
/**
* Set all existing data as expired
*/
expire() {
for (let i = 0; i < this.data.length; i++) {
this.data[i].class = "expired";
}
}
/**
* Return data
*
*/
get() {
return this.data;
}
}

422
home.css Normal file
View File

@ -0,0 +1,422 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
@import "~roboto-fontface/css/roboto/roboto-fontface.css";
@keyframes home-window-display-flash {
0% {
top: -2px;
opacity: 0;
box-shadow: 0 0 0 transparent;
}
20% {
height: 20px;
box-shadow: 0 0 50px #fff;
}
50% {
opacity: 0.5;
box-shadow: 0 0 10px #fff;
}
100% {
top: 100%;
height: 0;
opacity: 0;
box-shadow: 0 0 0 transparent;
}
}
.home-window-display {
}
.home-window-display::after {
opacity: 0;
z-index: 2;
content: " ";
display: block;
position: absolute;
width: 100%;
height: 0;
top: -2px;
left: 0;
right: 0;
background: #fff;
animation-name: home-window-display-flash;
animation-duration: 0.3s;
animation-iteration-count: 1;
box-shadow: 0 0 10px #fff;
}
#home {
height: 100%;
display: flex;
flex-direction: column;
font: 1em "Roboto", sans-serif;
}
#home-header {
flex: 0 0 40px;
font-size: 0.9em;
color: #fff;
width: 100%;
overflow: auto;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
#home-hd-title {
font-size: 1.1em;
padding: 0 0 0 20px;
font-weight: bold;
flex: 0 0 65px;
text-align: center;
overflow: hidden;
}
#home-hd-delay {
font-size: 0.95em;
display: flex;
flex: 0 0 70px;
flex-direction: row;
overflow: hidden;
margin: 0 10px;
color: #aaa;
text-decoration: none;
justify-items: center;
justify-content: center;
align-items: center;
}
@media (max-width: 768px) {
#home-hd-title {
padding: 0 0 0 10px;
}
#home-hd-delay {
flex: 0 0 60px;
}
}
#home-hd-delay-icon {
color: #bbb;
text-shadow: 0 0 3px #999;
transition: linear 0.2s color, text-shadow;
margin: 5px;
font-size: 0.54em;
}
#home-hd-delay-value {
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
word-wrap: none;
}
@keyframes home-hd-delay-icon-flash {
0% {
opacity: 1;
}
10% {
opacity: 0;
}
20% {
opacity: 1;
}
30% {
opacity: 0;
}
90% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes home-hd-delay-icon-working {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
#home-hd-delay-icon.green {
color: #1e8;
text-shadow: 0 0 3px #1e8;
}
#home-hd-delay-icon.yellow {
color: #ff4;
text-shadow: 0 0 3px #ff4;
}
#home-hd-delay-icon.orange {
color: #f80;
text-shadow: 0 0 3px #f80;
}
#home-hd-delay-icon.red {
color: #e11;
text-shadow: 0 0 3px #e11;
}
#home-hd-delay-icon.flash {
animation-name: home-hd-delay-icon-flash;
animation-duration: 1s;
animation-iteration-count: infinite;
}
#home-hd-delay-icon.working {
animation-name: home-hd-delay-icon-working;
animation-duration: 1.5s;
animation-iteration-count: infinite;
}
#home-hd-plus {
flex: 0 0;
padding: 0 13px;
text-decoration: none;
font-size: 22px;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
}
@keyframes home-hd-plus-icon-flash {
0% {
background: #a56;
}
20% {
background: #5a7;
}
40% {
background: #96a;
}
60% {
background: #379;
}
80% {
background: #da0;
}
100% {
background: #a56;
}
}
#home-hd-plus.working {
color: #fff;
background: #a56;
animation-name: home-hd-plus-icon-flash;
animation-duration: 10s;
animation-iteration-count: infinite;
animation-direction: normal;
transition: linear 2s background;
}
#home-hd-plus.working.intensify {
animation-duration: 3s;
}
#home-hd-tabs {
background: #333;
flex: auto;
overflow: hidden;
height: 100%;
display: flex;
flex-direction: row;
justify-content: left;
align-items: center;
}
#home-hd-tabs-tabs {
flex: auto;
overflow: hidden;
}
#home-hd-tabs-tabs > li {
flex: 0 0 180px;
display: flex;
position: relative;
padding: 0 15px;
opacity: 0.5;
color: #999;
}
#home-hd-tabs-tabs > li::after {
content: " ";
display: block;
position: absolute;
bottom: 0;
right: 10px;
left: 10px;
height: 0;
transition: all 0.1s linear;
transition-property: height, right, left;
}
#home-hd-tabs-tabs > li.active::after {
right: 0;
left: 0;
}
#home-hd-tabs-tabs > li.updated::after {
background: #fff3;
height: 2px;
}
#home-hd-tabs-tabs > li.error::after {
background: #d55;
height: 2px;
}
#home-hd-tabs-tabs > li > span.title {
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
}
#home-hd-tabs-tabs > li > span.title > span.type {
display: inline-block;
font-size: 0.85em;
font-weight: bold;
margin-right: 3px;
text-transform: uppercase;
color: #fff;
background: #222;
padding: 1px 4px;
border-radius: 2px;
}
#home-hd-tabs-tabs > li > .icon-close {
display: none;
}
#home-hd-tabs-tabs > li.active {
color: #fff;
opacity: 1;
}
#home-hd-tabs-tabs > li.active > span.title {
padding-right: 15px;
}
#home-hd-tabs-tabs > li.active > .icon-close {
display: block;
position: absolute;
top: 50%;
right: 10px;
margin-top: -5px;
color: #fff6;
}
#home-hd-tabs-list {
display: flex;
font-size: 22px;
flex: 0 0;
padding: 0 13px;
flex-direction: column;
justify-content: center;
height: 100%;
text-decoration: none;
box-shadow: 0 0 3px #333;
}
#home-content {
flex: auto;
display: flex;
justify-content: center;
flex-direction: column;
color: #fff;
font-size: 1.2em;
}
@media (max-width: 768px) {
#home-content {
font-size: 1em;
}
}
#home-content-wrap {
max-width: 520px;
margin: 50px auto;
padding: 0 30px;
text-align: center;
}
#home-content h1 {
margin: 20px 0;
}
#home-content p {
margin: 10px 0;
font-size: 0.9em;
color: #eee;
line-height: 1.6;
}
#home-content p.secondary {
margin: 5px 0;
line-height: 1.5;
font-size: 0.7em;
color: #aaa;
}
#home-content p a {
color: #e9a;
}
#home-content hr {
height: 2px;
background: #3c3c3c;
border: none;
margin: 30px 0;
}
#home-content-connect {
padding: 5px;
display: inline-block;
width: 20px;
height: 20px;
cursor: pointer;
}

617
home.vue Normal file
View File

@ -0,0 +1,617 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<div id="home">
<header id="home-header">
<h1 id="home-hd-title">Sshwifty</h1>
<a id="home-hd-delay" href="javascript:;" @click="showDelayWindow">
<span
id="home-hd-delay-icon"
class="icon icon-point1"
:class="socket.classStyle"
></span>
<span v-if="socket.message.length > 0" id="home-hd-delay-value">{{
socket.message
}}</span>
</a>
<a
id="home-hd-plus"
class="icon icon-plus1"
href="javascript:;"
:class="{
working: connector.inputting,
intensify: connector.inputting && !windows.connect,
}"
@click="showConnectWindow"
></a>
<tabs
id="home-hd-tabs"
:tab="tab.current"
:tabs="tab.tabs"
tabs-class="tab1"
list-trigger-class="icon icon-more1"
@current="switchTab"
@retap="retapTab"
@list="showTabsWindow"
@close="closeTab"
></tabs>
</header>
<screens
id="home-content"
:screen="tab.current"
:screens="tab.tabs"
:view-port="viewPort"
@stopped="tabStopped"
@warning="tabWarning"
@info="tabInfo"
@updated="tabUpdated"
>
<div id="home-content-wrap">
<h1>Hi, this is Sshwifty</h1>
<p>
An Open Source Web SSH Client that enables you to connect to SSH
servers without downloading any additional software.
</p>
<p>
To get started, click the
<span
id="home-content-connect"
class="icon icon-plus1"
@click="showConnectWindow"
></span>
icon near the top left corner.
</p>
<div v-if="serverMessage.length > 0">
<hr />
<p class="secondary" v-html="serverMessage"></p>
</div>
</div>
</screens>
<connect-widget
:inputting="connector.inputting"
:display="windows.connect"
:connectors="connector.connectors"
:presets="presets"
:restricted-to-presets="restrictedToPresets"
:knowns="connector.knowns"
:knowns-launcher-builder="buildknownLauncher"
:knowns-export="exportKnowns"
:knowns-import="importKnowns"
:busy="connector.busy"
@display="windows.connect = $event"
@connector-select="connectNew"
@known-select="connectKnown"
@known-remove="removeKnown"
@preset-select="connectPreset"
@known-clear-session="clearSessionKnown"
>
<connector
:connector="connector.connector"
@cancel="cancelConnection"
@done="connectionSucceed"
>
</connector>
</connect-widget>
<status-widget
:class="socket.windowClass"
:display="windows.delay"
:status="socket.status"
@display="windows.delay = $event"
></status-widget>
<tab-window
:tab="tab.current"
:tabs="tab.tabs"
:display="windows.tabs"
tabs-class="tab1 tab1-list"
@display="windows.tabs = $event"
@current="switchTab"
@retap="retapTab"
@close="closeTab"
></tab-window>
</div>
</template>
<script>
import "./home.css";
import ConnectWidget from "./widgets/connect.vue";
import StatusWidget from "./widgets/status.vue";
import Connector from "./widgets/connector.vue";
import Tabs from "./widgets/tabs.vue";
import TabWindow from "./widgets/tab_window.vue";
import Screens from "./widgets/screens.vue";
import * as home_socket from "./home_socketctl.js";
import * as home_history from "./home_historyctl.js";
import * as presets from "./commands/presets.js";
const BACKEND_CONNECT_ERROR =
"Unable to connect to the Sshwifty backend server: ";
const BACKEND_REQUEST_ERROR = "Unable to perform request: ";
export default {
components: {
"connect-widget": ConnectWidget,
"status-widget": StatusWidget,
connector: Connector,
tabs: Tabs,
"tab-window": TabWindow,
screens: Screens,
},
props: {
hostPath: {
type: String,
default: "",
},
query: {
type: String,
default: "",
},
connection: {
type: Object,
default: () => null,
},
controls: {
type: Object,
default: () => null,
},
commands: {
type: Object,
default: () => null,
},
serverMessage: {
type: String,
default: "",
},
presetData: {
type: Object,
default: () => new presets.Presets([]),
},
restrictedToPresets: {
type: Boolean,
default: () => false,
},
viewPort: {
type: Object,
default: () => null,
},
},
data() {
let history = home_history.build(this);
return {
ticker: null,
windows: {
delay: false,
connect: false,
tabs: false,
},
socket: home_socket.build(this),
connector: {
historyRec: history,
connector: null,
connectors: this.commands.all(),
inputting: false,
acquired: false,
busy: false,
knowns: history.all(),
},
presets: this.commands.mergePresets(this.presetData),
tab: {
current: -1,
lastID: 0,
tabs: [],
},
};
},
mounted() {
this.ticker = setInterval(() => {
this.tick();
}, 1000);
if (this.query.length > 1 && this.query.indexOf("+") === 0) {
this.connectLaunch(this.query.slice(1, this.query.length), (success) => {
if (!success) {
return;
}
this.$emit("navigate-to", "");
});
}
window.addEventListener("beforeunload", this.onBrowserClose);
},
beforeDestroy() {
window.removeEventListener("beforeunload", this.onBrowserClose);
if (this.ticker === null) {
clearInterval(this.ticker);
this.ticker = null;
}
},
methods: {
onBrowserClose(e) {
if (this.tab.current < 0) {
return undefined;
}
const msg = "Some tabs are still open, are you sure you want to exit?";
(e || window.event).returnValue = msg;
return msg;
},
tick() {
let now = new Date();
this.socket.update(now, this);
},
closeAllWindow(e) {
for (let i in this.windows) {
this.windows[i] = false;
}
},
showDelayWindow() {
this.closeAllWindow();
this.windows.delay = true;
},
showConnectWindow() {
this.closeAllWindow();
this.windows.connect = true;
},
showTabsWindow() {
this.closeAllWindow();
this.windows.tabs = true;
},
async getStreamThenRun(run, end) {
let errStr = null;
try {
let conn = await this.connection.get(this.socket);
try {
run(conn);
} catch (e) {
errStr = BACKEND_REQUEST_ERROR + e;
process.env.NODE_ENV === "development" && console.trace(e);
}
} catch (e) {
errStr = BACKEND_CONNECT_ERROR + e;
process.env.NODE_ENV === "development" && console.trace(e);
}
end();
if (errStr !== null) {
alert(errStr);
}
},
runConnect(callback) {
if (this.connector.acquired) {
return;
}
this.connector.acquired = true;
this.connector.busy = true;
this.getStreamThenRun(
(stream) => {
this.connector.busy = false;
callback(stream);
},
() => {
this.connector.busy = false;
this.connector.acquired = false;
},
);
},
connectNew(connector) {
const self = this;
self.runConnect((stream) => {
self.connector.connector = {
id: connector.id(),
name: connector.name(),
description: connector.description(),
wizard: connector.wizard(
stream,
self.controls,
self.connector.historyRec,
presets.emptyPreset(),
null,
false,
() => {},
),
};
self.connector.inputting = true;
});
},
connectPreset(preset) {
const self = this;
self.runConnect((stream) => {
self.connector.connector = {
id: preset.command.id(),
name: preset.command.name(),
description: preset.command.description(),
wizard: preset.command.wizard(
stream,
self.controls,
self.connector.historyRec,
preset.preset,
null,
[],
() => {},
),
};
self.connector.inputting = true;
});
},
getConnectorByType(type) {
let connector = null;
for (let c in this.connector.connectors) {
if (this.connector.connectors[c].name() !== type) {
continue;
}
connector = this.connector.connectors[c];
}
return connector;
},
connectKnown(known) {
const self = this;
self.runConnect((stream) => {
let connector = self.getConnectorByType(known.type);
if (!connector) {
alert("Unknown connector: " + known.type);
self.connector.inputting = false;
return;
}
self.connector.connector = {
id: connector.id(),
name: connector.name(),
description: connector.description(),
wizard: connector.execute(
stream,
self.controls,
self.connector.historyRec,
known.data,
known.session,
known.keptSessions,
() => {
self.connector.knowns = self.connector.historyRec.all();
},
),
};
self.connector.inputting = true;
});
},
parseConnectLauncher(ll) {
let llSeparatorIdx = ll.indexOf(":");
// Type must contain at least one charater
if (llSeparatorIdx <= 0) {
throw new Error("Invalid Launcher string");
}
return {
type: ll.slice(0, llSeparatorIdx),
query: ll.slice(llSeparatorIdx + 1, ll.length),
};
},
connectLaunch(launcher, done) {
this.showConnectWindow();
this.runConnect((stream) => {
let ll = this.parseConnectLauncher(launcher),
connector = this.getConnectorByType(ll.type);
if (!connector) {
alert("Unknown connector: " + ll.type);
this.connector.inputting = false;
return;
}
const self = this;
this.connector.connector = {
id: connector.id(),
name: connector.name(),
description: connector.description(),
wizard: connector.launch(
stream,
this.controls,
this.connector.historyRec,
ll.query,
(n) => {
self.connector.knowns = self.connector.historyRec.all();
done(n.data().success);
},
),
};
this.connector.inputting = true;
});
},
buildknownLauncher(known) {
let connector = this.getConnectorByType(known.type);
if (!connector) {
return;
}
return this.hostPath + "#+" + connector.launcher(known.data);
},
exportKnowns() {
return this.connector.historyRec.export();
},
importKnowns(d) {
this.connector.historyRec.import(d);
this.connector.knowns = this.connector.historyRec.all();
},
removeKnown(uid) {
this.connector.historyRec.del(uid);
this.connector.knowns = this.connector.historyRec.all();
},
clearSessionKnown(uid) {
this.connector.historyRec.clearSession(uid);
this.connector.knowns = this.connector.historyRec.all();
},
cancelConnection() {
this.connector.inputting = false;
this.connector.acquired = false;
},
connectionSucceed(data) {
this.connector.inputting = false;
this.connector.acquired = false;
this.windows.connect = false;
this.addToTab(data);
this.$emit("tab-opened", this.tab.tabs);
},
async addToTab(data) {
await this.switchTab(
this.tab.tabs.push({
id: this.tab.lastID++,
name: data.name,
info: data.info,
control: data.control,
ui: data.ui,
toolbar: false,
indicator: {
level: "",
message: "",
updated: false,
},
status: {
closing: false,
},
}) - 1,
);
},
removeFromTab(index) {
let isLast = index === this.tab.tabs.length - 1;
this.tab.tabs.splice(index, 1);
this.tab.current = isLast ? this.tab.tabs.length - 1 : index;
},
async switchTab(to) {
if (this.tab.current >= 0) {
await this.tab.tabs[this.tab.current].control.disabled();
}
this.tab.current = to;
this.tab.tabs[this.tab.current].indicator.updated = false;
await this.tab.tabs[this.tab.current].control.enabled();
},
async retapTab(tab) {
this.tab.tabs[tab].toolbar = !this.tab.tabs[tab].toolbar;
await this.tab.tabs[tab].control.retap(this.tab.tabs[tab].toolbar);
},
async closeTab(index) {
if (this.tab.tabs[index].status.closing) {
return;
}
this.tab.tabs[index].status.closing = true;
try {
this.tab.tabs[index].control.disabled();
await this.tab.tabs[index].control.close();
} catch (e) {
alert("Cannot close tab due to error: " + e);
process.env.NODE_ENV === "development" && console.trace(e);
}
this.removeFromTab(index);
this.$emit("tab-closed", this.tab.tabs);
},
tabStopped(index, reason) {
if (reason !== null) {
this.tab.tabs[index].indicator.message = "" + reason;
this.tab.tabs[index].indicator.level = "error";
} else {
this.tab.tabs[index].indicator.message = "";
this.tab.tabs[index].indicator.level = "";
}
},
tabMessage(index, msg, type) {
if (msg.toDismiss) {
if (
this.tab.tabs[index].indicator.message !== msg.text ||
this.tab.tabs[index].indicator.level !== type
) {
return;
}
this.tab.tabs[index].indicator.message = "";
this.tab.tabs[index].indicator.level = "";
return;
}
this.tab.tabs[index].indicator.message = msg.text;
this.tab.tabs[index].indicator.level = type;
},
tabWarning(index, msg) {
this.tabMessage(index, msg, "warning");
},
tabInfo(index, msg) {
this.tabMessage(index, msg, "info");
},
tabUpdated(index) {
this.$emit("tab-updated", this.tab.tabs);
this.tab.tabs[index].indicator.updated = index !== this.tab.current;
},
},
};
</script>

58
home_historyctl.js Normal file
View File

@ -0,0 +1,58 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 { History } from "./commands/history.js";
export function build(ctx) {
let rec = [];
// This renames "knowns" to "sshwifty-knowns"
// TODO: Remove this after some few years
try {
let oldStore = localStorage.getItem("knowns");
if (oldStore) {
localStorage.setItem("sshwifty-knowns", oldStore);
localStorage.removeItem("knowns");
}
} catch (e) {
// Do nothing
}
try {
rec = JSON.parse(localStorage.getItem("sshwifty-knowns"));
if (!rec) {
rec = [];
}
} catch (e) {
alert("Unable to load data of Known remotes: " + e);
}
return new History(
rec,
(h, d) => {
try {
localStorage.setItem("sshwifty-knowns", JSON.stringify(d));
ctx.connector.knowns = h.all();
} catch (e) {
alert("Unable to save remote history due to error: " + e);
}
},
64,
);
}

223
home_socketctl.js Normal file
View File

@ -0,0 +1,223 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as history from "./history.js";
import { ECHO_FAILED } from "./socket.js";
export function build(ctx) {
const connectionStatusNotConnected = "Sshwifty is ready to connect";
const connectionStatusConnecting =
"Connecting to Sshwifty backend server. It should only take " +
"less than a second, or two";
const connectionStatusDisconnected =
"Sshwifty is disconnected from it's backend server";
const connectionStatusConnected =
"Sshwifty is connected to it's backend server, user interface operational";
const connectionStatusUnmeasurable =
"Unable to measure connection delay. The connection maybe very " +
"busy or already lost";
const connectionDelayGood =
"Connection delay is low, operation should be very responsive";
const connectionDelayFair =
"Experiencing minor connection delay, operation should be responded " +
"within a reasonable time";
const connectionDelayMedian =
"Experiencing median connection delay, consider to slow down your input " +
"to avoid misoperation";
const connectionDelayHeavy =
"Experiencing bad connection delay, operation may freeze at any moment. " +
"Consider to pause your input until remote is responsive";
const buildEmptyHistory = () => {
let r = [];
for (let i = 0; i < 32; i++) {
r.push({ data: 0, class: "" });
}
return r;
};
let isClosed = false,
inboundPerSecond = 0,
outboundPerSecond = 0,
trafficPreSecondNextUpdate = new Date(),
inboundPre10Seconds = 0,
outboundPre10Seconds = 0,
trafficPre10sNextUpdate = new Date(),
inboundHistory = new history.Records(buildEmptyHistory()),
outboundHistory = new history.Records(buildEmptyHistory()),
trafficSamples = 0;
let delayHistory = new history.Records(buildEmptyHistory()),
delaySamples = 0,
delayPerInterval = 0;
return {
update(time) {
if (isClosed) {
return;
}
if (time >= trafficPreSecondNextUpdate) {
trafficPreSecondNextUpdate = new Date(time.getTime() + 1000);
inboundPre10Seconds += inboundPerSecond;
outboundPre10Seconds += outboundPerSecond;
this.status.inbound = inboundPerSecond;
this.status.outbound = outboundPerSecond;
inboundPerSecond = 0;
outboundPerSecond = 0;
trafficSamples++;
}
if (time >= trafficPre10sNextUpdate) {
trafficPre10sNextUpdate = new Date(time.getTime() + 10000);
if (trafficSamples > 0) {
inboundHistory.update(inboundPre10Seconds / trafficSamples);
outboundHistory.update(outboundPre10Seconds / trafficSamples);
inboundPre10Seconds = 0;
outboundPre10Seconds = 0;
trafficSamples = 0;
}
if (delaySamples > 0) {
delayHistory.update(delayPerInterval / delaySamples);
delaySamples = 0;
delayPerInterval = 0;
}
}
},
classStyle: "",
windowClass: "",
message: "",
status: {
description: connectionStatusNotConnected,
delay: 0,
delayHistory: delayHistory.get(),
inbound: 0,
inboundHistory: inboundHistory.get(),
outbound: 0,
outboundHistory: outboundHistory.get(),
},
connecting() {
isClosed = false;
this.message = "--";
this.classStyle = "working";
this.windowClass = "";
this.status.description = connectionStatusConnecting;
},
connected() {
isClosed = false;
this.message = "??";
this.classStyle = "working";
this.windowClass = "";
this.status.description = connectionStatusConnected;
},
traffic(inb, outb) {
inboundPerSecond += inb;
outboundPerSecond += outb;
},
echo(delay) {
delayPerInterval += delay > 0 ? delay : 0;
delaySamples++;
if (delay == ECHO_FAILED) {
this.status.delay = -1;
this.message = "";
this.classStyle = "red flash";
this.windowClass = "red";
this.status.description = connectionStatusUnmeasurable;
return;
}
let avgDelay = Math.round(delayPerInterval / delaySamples);
this.message = Number(avgDelay).toLocaleString() + "ms";
this.status.delay = avgDelay;
if (avgDelay < 30) {
this.classStyle = "green";
this.windowClass = "green";
this.status.description =
connectionStatusConnected + ". " + connectionDelayGood;
} else if (avgDelay < 100) {
this.classStyle = "yellow";
this.windowClass = "yellow";
this.status.description =
connectionStatusConnected + ". " + connectionDelayFair;
} else if (avgDelay < 300) {
this.classStyle = "orange";
this.windowClass = "orange";
this.status.description =
connectionStatusConnected + ". " + connectionDelayMedian;
} else {
this.classStyle = "red";
this.windowClass = "red";
this.status.description =
connectionStatusConnected + ". " + connectionDelayHeavy;
}
},
close(e) {
isClosed = true;
delayHistory.expire();
inboundHistory.expire();
outboundHistory.expire();
ctx.connector.inputting = false;
if (e === null) {
this.message = "";
this.classStyle = "";
this.status.description = connectionStatusDisconnected;
return;
}
this.status.delay = -1;
this.message = "ERR";
this.classStyle = "red flash";
this.windowClass = "red";
this.status.description = connectionStatusDisconnected + ": " + e;
},
failed(e) {
isClosed = true;
ctx.connector.inputting = false;
if (e.code) {
this.message = "E" + e.code;
} else {
this.message = "E????";
}
this.status.delay = -1;
this.classStyle = "red flash";
this.windowClass = "red";
this.status.description = connectionStatusDisconnected + ". Error: " + e;
},
};
}

64
index.html Normal file
View File

@ -0,0 +1,64 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sshwifty Web SSH Client</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="landing">
<div id="landing-message">
<div id="landing-message-logo"></div>
<h1 id="landing-message-title">Loading Sshwifty</h1>
<div id="landing-message-info">
<p>
Client is currently being loaded. Should only take a few seconds,
please wait
</p>
<noscript>
<p>
Also, surely you smart people knows that application such like
this one require JavaScript to run :)
</p>
</noscript>
<p class="copy copy-first">
Copyright &copy; 2019-2023 Ni Rui &lt;ranqus@gmail.com&gt;
</p>
<p class="copy">
<a href="https://github.com/nirui/sshwifty" target="blank">
Source code
</a>
<a href="/sshwifty/assets/DEPENDENCIES.md" target="blank">
Third-party
</a>
<a href="/sshwifty/assets/README.md" target="blank"> Readme </a>
<a href="/sshwifty/assets/LICENSE.md" target="blank"> License </a>
</p>
</div>
</div>
</div>
</body>
</html>

132
landing.css Normal file
View File

@ -0,0 +1,132 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
body.landing {
background: #945;
}
#landing {
min-height: 100%;
font-family: Arial, Helvetica, sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
justify-items: center;
color: #fff;
text-align: center;
}
#landing-message {
font-size: 0.9em;
max-width: 500px;
margin: 0 auto;
padding: 50px;
flex: 0 0;
}
#landing-message a {
text-decoration: none;
color: #fab;
}
#landing-message p {
margin: 10px 0;
}
#landing-message p.copy {
margin: 20px 0 10px 0;
color: #fab;
}
#landing-message p.copy.copy-first {
margin-top: 50px;
}
#landing-message p.copy a {
border: 1px solid #fab;
display: inline-block;
padding: 3px 7px;
margin: 5px;
border-radius: 5px;
line-height: initial;
}
#landing-message-logo {
background: url("./widgets/busy.svg") center center no-repeat;
background-size: contain;
width: 100%;
height: 200px;
}
#landing-message-info {
margin-top: 50px;
font-size: 0.9em;
}
#auth {
width: 100%;
min-height: 100%;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
#auth-frame {
flex: 0 0;
padding: 10px;
}
#auth-content {
background: #333;
box-shadow: 0 0 3px #111;
padding: 30px;
max-width: 380px;
margin: 0 auto;
font-size: 1em;
}
#auth-content > h1 {
margin-bottom: 20px;
color: #fab;
font-size: 1.2em;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
}
#auth-content > h1:after {
content: "\2731";
margin-left: 5px;
}
#auth-content > form {
font-size: 0.9em;
}
#auth-content > form .field:last-child {
margin-top: 30px;
}
#auth-content > form > fieldset {
margin-top: 30px;
}

45
loading.vue Normal file
View File

@ -0,0 +1,45 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<div id="app-loading">
<div id="app-loading-frame">
<div v-if="error.length <= 0" id="app-loading-icon"></div>
<div v-else id="app-loading-error">&times;</div>
<h1 v-if="error.length <= 0" id="app-loading-title">
Preparing client application
</h1>
<h1 v-else id="app-loading-title" class="error">
{{ error }}
</h1>
</div>
</div>
</template>
<script>
export default {
props: {
error: {
type: String,
default: "",
},
},
};
</script>

1276
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,12 @@
"main": "",
"devDependencies": {
"@azurity/pure-nerd-font": "^3.0.1",
"@babel/core": "^7.23.5",
"@babel/core": "^7.23.7",
"@babel/eslint-parser": "^7.23.3",
"@babel/plugin-transform-runtime": "^7.23.4",
"@babel/preset-env": "^7.23.5",
"@babel/register": "^7.22.15",
"@babel/runtime": "^7.23.5",
"@babel/plugin-transform-runtime": "^7.23.7",
"@babel/preset-env": "^7.23.7",
"@babel/register": "^7.23.7",
"@babel/runtime": "^7.23.7",
"@xterm/addon-fit": "^0.9.0-beta.1",
"@xterm/addon-unicode11": "^0.7.0-beta.1",
"@xterm/addon-web-links": "^0.10.0-beta.1",
@ -23,16 +23,16 @@
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"cwebp-bin": "^8.0.0",
"eslint": "^8.55.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-vue": "^9.19.2",
"eslint-webpack-plugin": "^4.0.1",
"favicons": "^7.1.4",
"favicons": "^7.1.5",
"fontfaceobserver": "^2.3.0",
"hack-font": "^3.3.0",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.3",
"html-webpack-plugin": "^5.6.0",
"iconv-lite": "^0.6.3",
"image-minimizer-webpack-plugin": "^3.8.3",
"imagemin": "^8.0.1",
@ -44,10 +44,10 @@
"mini-css-extract-plugin": "^2.7.6",
"mocha": "^10.2.0",
"normalize.css": "^8.0.1",
"prettier": "^3.1.0",
"prettier": "^3.1.1",
"roboto-fontface": "^0.10.0",
"style-loader": "^3.3.3",
"terser-webpack-plugin": "^5.3.9",
"terser-webpack-plugin": "^5.3.10",
"vue": "^2.6.14",
"vue-loader": "^15.9.8",
"webpack": "^5.89.0",

4
robots.txt Normal file
View File

@ -0,0 +1,4 @@
user-agent: *
Disallow: /
Allow: /$

397
socket.js Normal file
View File

@ -0,0 +1,397 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as crypt from "./crypto.js";
import * as reader from "./stream/reader.js";
import * as sender from "./stream/sender.js";
import * as streams from "./stream/streams.js";
import * as xhr from "./xhr.js";
export const ECHO_FAILED = streams.ECHO_FAILED;
const maxSenderDelay = 200;
const minSenderDelay = 30;
class Dial {
/**
* constructor
*
* @param {string} address Address to the Websocket server
* @param {number} Dial timeout
* @param {object} privateKey String key that will be used to encrypt and
* decrypt socket traffic
*
*/
constructor(address, timeout, privateKey) {
this.address = address;
this.timeout = timeout;
this.privateKey = privateKey;
this.keepAliveTicker = null;
}
/**
* Connect to the remote server
*
* @param {string} address Target URL address
* @param {number} timeout Connect timeout
*
* @returns {Promise<WebSocket>} When connection is established
*
*/
connect(address, timeout) {
const self = this;
return new Promise((resolve, reject) => {
let ws = new WebSocket(address.webSocket),
promised = false,
timeoutTimer = setTimeout(() => {
ws.close();
}, timeout),
myRes = (w) => {
if (promised) {
return;
}
clearTimeout(timeoutTimer);
promised = true;
return resolve(w);
},
myRej = (e) => {
if (promised) {
return;
}
clearTimeout(timeoutTimer);
promised = true;
return reject(e);
};
if (!self.keepAliveTicker) {
self.keepAliveTicker = setInterval(() => {
xhr.options(address.keepAlive, {});
}, self.timeout);
}
ws.addEventListener("open", (_event) => {
myRes(ws);
});
ws.addEventListener("close", (event) => {
event.toString = () => {
return "WebSocket Error (" + event.code + ")";
};
myRej(event);
clearInterval(self.keepAliveTicker);
self.keepAliveTicker = null;
});
ws.addEventListener("error", (_event) => {
ws.close();
clearInterval(self.keepAliveTicker);
self.keepAliveTicker = null;
});
});
}
/**
* Build an socket encrypt and decrypt key string
*
*/
async buildKeyString() {
return this.privateKey.fetch();
}
/**
* Build encrypt and decrypt key
*
*/
async buildKey() {
let kStr = await this.buildKeyString();
return await crypt.buildGCMKey(kStr);
}
/**
* Connect to the server
*
* @param {object} callbacks Callbacks
*
* @returns {object} A pair of ReadWriter which can be used to read and
* send data to the underlaying websocket connection
*
*/
async dial(callbacks) {
let ws = await this.connect(this.address, this.timeout);
try {
let rd = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return new Promise((resolve) => {
let bufferReader = new FileReader();
bufferReader.onload = (event) => {
let d = new Uint8Array(event.target.result);
resolve(d);
callbacks.inboundUnpacked(d);
};
bufferReader.readAsArrayBuffer(data);
});
});
ws.addEventListener("message", (event) => {
callbacks.inbound(event.data);
rd.feed(event.data);
});
ws.addEventListener("error", (event) => {
event.toString = () => {
return (
"WebSocket Error (" + (event.code ? event.code : "Unknown") + ")"
);
};
rd.closeWithReason(event);
});
ws.addEventListener("close", (_event) => {
rd.closeWithReason("Connection is closed");
});
let sdDataConvert = (rawData) => {
return rawData;
},
getSdDataConvert = () => {
return sdDataConvert;
},
sd = new sender.Sender(
async (rawData) => {
try {
let data = await getSdDataConvert()(rawData);
ws.send(data.buffer);
callbacks.outbound(data);
} catch (e) {
ws.close();
rd.closeWithReason(e);
if (process.env.NODE_ENV === "development") {
console.error(e);
}
throw e;
}
},
4096 - 64, // Server has a 4096 bytes receive buffer, can be no greater,
minSenderDelay, // 30ms input delay
10, // max 10 buffered requests
);
let senderNonce = crypt.generateNonce();
sd.send(senderNonce);
let receiverNonce = await reader.readN(rd, crypt.GCMNonceSize);
let key = await this.buildKey();
sdDataConvert = async (rawData) => {
let encoded = await crypt.encryptGCM(key, senderNonce, rawData);
crypt.increaseNonce(senderNonce);
let dataToSend = new Uint8Array(encoded.byteLength + 2);
dataToSend[0] = (encoded.byteLength >> 8) & 0xff;
dataToSend[1] = encoded.byteLength & 0xff;
dataToSend.set(new Uint8Array(encoded), 2);
return dataToSend;
};
let cgmReader = new reader.Multiple(async (r) => {
try {
let dSizeBytes = await reader.readN(rd, 2),
dSize = 0;
dSize = dSizeBytes[0];
dSize <<= 8;
dSize |= dSizeBytes[1];
let decoded = await crypt.decryptGCM(
key,
receiverNonce,
await reader.readN(rd, dSize),
);
crypt.increaseNonce(receiverNonce);
r.feed(
new reader.Buffer(new Uint8Array(decoded), () => {}),
() => {},
);
} catch (e) {
r.closeWithReason(e);
}
});
return {
reader: cgmReader,
sender: sd,
ws: ws,
};
} catch (e) {
ws.close();
throw e;
}
}
}
export class Socket {
/**
* constructor
*
* @param {string} address Address of the WebSocket server
* @param {object} privateKey String key that will be used to encrypt and
* decrypt socket traffic
* @param {number} timeout Dial timeout
* @param {number} echoInterval Echo interval
*/
constructor(address, privateKey, timeout, echoInterval) {
this.dial = new Dial(address, timeout, privateKey);
this.echoInterval = echoInterval;
this.streamHandler = null;
}
/**
* Return a stream handler
*
* @param {object} callbacks A group of callbacks to call when needed
*
* @returns {Promise<streams.Streams>} The stream manager
*
*/
async get(callbacks) {
let self = this;
if (this.streamHandler) {
return this.streamHandler;
}
callbacks.connecting();
const receiveToPauseFactor = 6,
minReceivedToPause = 1024 * 16;
let streamPaused = false,
currentReceived = 0,
currentUnpacked = 0;
const shouldPause = () => {
return (
currentReceived > minReceivedToPause &&
currentReceived > currentUnpacked * receiveToPauseFactor
);
};
try {
let conn = await this.dial.dial({
inbound(data) {
currentReceived += data.size;
callbacks.traffic(data.size, 0);
},
inboundUnpacked(data) {
currentUnpacked += data.length;
if (currentUnpacked >= currentReceived) {
currentUnpacked = 0;
currentReceived = 0;
}
if (self.streamHandler !== null) {
if (streamPaused && !shouldPause()) {
streamPaused = false;
self.streamHandler.resume();
return;
} else if (!streamPaused && shouldPause()) {
streamPaused = true;
self.streamHandler.pause();
return;
}
}
},
outbound(data) {
callbacks.traffic(0, data.length);
},
});
let streamHandler = new streams.Streams(conn.reader, conn.sender, {
echoInterval: self.echoInterval,
echoUpdater(delay) {
const sendDelay = delay / 2;
if (sendDelay > maxSenderDelay) {
conn.sender.setDelay(maxSenderDelay);
} else if (sendDelay < minSenderDelay) {
conn.sender.setDelay(minSenderDelay);
} else {
conn.sender.setDelay(sendDelay);
}
return callbacks.echo(delay);
},
cleared(e) {
if (self.streamHandler === null) {
return;
}
self.streamHandler = null;
// Close connection first otherwise we may
// risk sending things out
conn.ws.close();
callbacks.close(e);
},
});
callbacks.connected();
streamHandler.serve().catch((e) => {
if (process.env.NODE_ENV !== "development") {
return;
}
console.trace(e);
});
this.streamHandler = streamHandler;
} catch (e) {
callbacks.failed(e);
throw e;
}
return this.streamHandler;
}
}

22
sshwifty.svg Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="100.000000pt" height="75.000000pt" viewBox="0 0 100.000000 75.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,75.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M104 557 c-62 -43 -85 -82 -89 -154 -6 -87 4 -137 39 -195 33 -56 71
-74 95 -45 9 12 4 25 -24 67 -34 50 -35 55 -35 148 l0 96 49 45 c45 43 56 71
26 71 -7 0 -35 -15 -61 -33z"/>
<path d="M870 575 c-14 -16 -11 -28 24 -120 9 -24 15 -74 16 -126 0 -105 14
-127 56 -88 26 23 26 26 20 113 -6 90 -35 176 -73 218 -20 22 -27 22 -43 3z"/>
<path d="M251 537 c-59 -72 -51 -266 14 -327 l25 -24 21 20 20 20 -27 47 c-21
38 -26 61 -26 117 -1 38 5 83 12 99 20 49 -9 84 -39 48z"/>
<path d="M636 511 c-17 -19 -17 -22 9 -65 24 -41 26 -53 23 -133 -3 -84 -2
-88 19 -91 49 -7 81 138 49 223 -16 40 -56 85 -76 85 -4 0 -15 -9 -24 -19z"/>
<path d="M412 469 c-32 -21 -52 -63 -52 -109 0 -40 63 -94 99 -85 35 8 81 59
81 91 0 45 -40 102 -81 117 -9 3 -30 -4 -47 -14z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

109
stream/common.js Normal file
View File

@ -0,0 +1,109 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
/**
* Get one unsafe random number
*
* @param {number} min Min value (included)
* @param {number} max Max value (not included)
*
* @returns {number} Get random number
*
*/
export function getRand(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
/**
* Get a group of random number
*
* @param {number} n How many number to get
* @param {number} min Min value (included)
* @param {number} max Max value (not included)
*
* @returns {Array<number>} A group of random number
*/
export function getRands(n, min, max) {
let r = [];
for (let i = 0; i < n; i++) {
r.push(getRand(min, max));
}
return r;
}
/**
* Separate given buffer to multiple ones based on input max length
*
* @param {Uint8Array} buf Buffer to separate
* @param {number} max Max length of each buffer
*
* @returns {Array<Uint8Array>} Separated buffers
*
*/
export function separateBuffer(buf, max) {
let start = 0,
result = [];
while (start < buf.length) {
let remain = buf.length - start;
if (remain <= max) {
result.push(buf.slice(start, start + remain));
return result;
}
remain = max;
result.push(buf.slice(start, start + remain));
start += remain;
}
}
/**
* Create an Uint8Array out of given binary string
*
* @param {string} str binary string
*
* @returns {Uint8Array} Separated buffers
*
*/
export function buildBufferFromString(str) {
let r = [],
t = [];
for (let i in str) {
let c = str.charCodeAt(i);
while (c > 0xff) {
t.push(c & 0xff);
c >>= 8;
}
r.push(c);
for (let j = t.length; j > 0; j--) {
r.push(t[j]);
}
t = [];
}
return new Uint8Array(r);
}

41
stream/common_test.js Normal file
View File

@ -0,0 +1,41 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 assert from "assert";
import * as common from "./common.js";
describe("Common", () => {
it("separateBuffer", async () => {
let resultArr = [];
const expected = new Uint8Array([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3,
4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7,
8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1,
2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5,
6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
]),
sepSeg = common.separateBuffer(expected, 16);
sepSeg.forEach((d) => {
resultArr.push(...d);
});
const result = new Uint8Array(resultArr);
assert.deepStrictEqual(result, expected);
});
});

31
stream/exception.js Normal file
View File

@ -0,0 +1,31 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
export default class Exception extends Error {
/**
* constructor
*
* @param {string} message error message
* @param {boolean} temporary whether or not the error is temporary
*
*/
constructor(message, temporary) {
super(message);
this.temporary = temporary;
}
}

264
stream/header.js Normal file
View File

@ -0,0 +1,264 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 Exception from "./exception.js";
export const CONTROL = 0x00;
export const STREAM = 0x40;
export const CLOSE = 0x80;
export const COMPLETED = 0xc0;
export const CONTROL_ECHO = 0x00;
export const CONTROL_PAUSESTREAM = 0x01;
export const CONTROL_RESUMESTREAM = 0x02;
const headerHeaderCutter = 0xc0;
const headerDataCutter = 0x3f;
export const HEADER_MAX_DATA = headerDataCutter;
export class Header {
/**
* constructor
*
* @param {number} headerByte one byte data of the header
*/
constructor(headerByte) {
this.headerByte = headerByte;
}
/**
* Return the header type
*
* @returns {number} Type number
*
*/
type() {
return this.headerByte & headerHeaderCutter;
}
/**
* Return the header data
*
* @returns {number} Data number
*
*/
data() {
return this.headerByte & headerDataCutter;
}
/**
* Set the reader data
*
* @param {number} data
*/
set(data) {
if (data > headerDataCutter) {
throw new Exception("data must not be greater than 0x3f", false);
}
this.headerByte |= headerDataCutter & data;
}
/**
* Return the header value
*
* @returns {number} Header byte data
*
*/
value() {
return this.headerByte;
}
}
export const STREAM_HEADER_BYTE_LENGTH = 2;
export const STREAM_MAX_LENGTH = 0x1fff;
export const STREAM_MAX_MARKER = 0x07;
const streamHeaderLengthFirstByteCutter = 0x1f;
export class Stream {
/**
* constructor
*
* @param {number} headerByte1 First header byte
* @param {number} headerByte2 Second header byte
*
*/
constructor(headerByte1, headerByte2) {
this.headerByte1 = headerByte1;
this.headerByte2 = headerByte2;
}
/**
* Return the marker data
*
* @returns {number} the marker
*
*/
marker() {
return this.headerByte1 >> 5;
}
/**
* Return the stream data length
*
* @returns {number} Length of the stream data
*
*/
length() {
let r = 0;
r |= this.headerByte1 & streamHeaderLengthFirstByteCutter;
r <<= 8;
r |= this.headerByte2;
return r;
}
/**
* Set the header
*
* @param {number} marker Header marker
* @param {number} length Stream data length
*
*/
set(marker, length) {
if (marker > STREAM_MAX_MARKER) {
throw new Exception("marker must not be greater than 0x07", false);
}
if (length > STREAM_MAX_LENGTH) {
throw new Exception("n must not be greater than 0x1fff", false);
}
this.headerByte1 =
(marker << 5) | ((length >> 8) & streamHeaderLengthFirstByteCutter);
this.headerByte2 = length & 0xff;
}
/**
* Return the header data
*
* @returns {Uint8Array} Header data
*
*/
buffer() {
return new Uint8Array([this.headerByte1, this.headerByte2]);
}
}
export class InitialStream extends Stream {
/**
* Return how large the data can be
*
* @returns {number} Max data size
*
*/
static maxDataSize() {
return 0x07ff;
}
/**
* constructor
*
* @param {number} headerByte1 First header byte
* @param {number} headerByte2 Second header byte
*
*/
constructor(headerByte1, headerByte2) {
super(headerByte1, headerByte2);
}
/**
* Return command ID
*
* @returns {number} Command ID
*
*/
command() {
return this.headerByte1 >> 4;
}
/**
* Return data
*
* @returns {number} Data
*
*/
data() {
let r = 0;
r |= this.headerByte1 & 0x07;
r <<= 8;
r |= this.headerByte2 & 0xff;
return r;
}
/**
* Return whether or not the respond is success
*
* @returns {boolean} True when the request is successful, false otherwise
*
*/
success() {
return (this.headerByte1 & 0x08) != 0;
}
/**
* Set the header
*
* @param {number} commandID Command ID
* @param {number} data Stream data
* @param {boolean} success Whether or not the request is successful
*
*/
set(commandID, data, success) {
if (commandID > 0x0f) {
throw new Exception("Command ID must not greater than 0x0f", false);
}
if (data > InitialStream.maxDataSize()) {
throw new Exception("Data must not greater than 0x07ff", false);
}
let dd = data & InitialStream.maxDataSize();
if (success) {
dd |= 0x0800;
}
this.headerByte1 = 0;
this.headerByte1 |= commandID << 4;
this.headerByte1 |= dd >> 8;
this.headerByte2 = 0;
this.headerByte2 |= dd & 0xff;
}
}
/**
* Build a new Header
*
* @param {number} h Header number
*
* @returns {Header} The header which been built
*
*/
export function header(h) {
return new Header(h);
}

56
stream/header_test.js Normal file
View File

@ -0,0 +1,56 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 assert from "assert";
import * as header from "./header.js";
describe("Header", () => {
it("Header", () => {
let h = new header.Header(header.ECHO);
h.set(63);
let n = new header.Header(h.value());
assert.strictEqual(h.type(), n.type());
assert.strictEqual(h.data(), n.data());
assert.strictEqual(n.type(), header.CONTROL);
assert.strictEqual(n.data(), 63);
});
it("Stream", () => {
let h = new header.Stream(0, 0);
h.set(header.STREAM_MAX_MARKER, header.STREAM_MAX_LENGTH);
assert.strictEqual(h.marker(), header.STREAM_MAX_MARKER);
assert.strictEqual(h.length(), header.STREAM_MAX_LENGTH);
assert.strictEqual(h.headerByte1, 0xff);
assert.strictEqual(h.headerByte2, 0xff);
});
it("InitialStream", () => {
let h = new header.InitialStream(0, 0);
h.set(15, 128, true);
assert.strictEqual(h.command(), 15);
assert.strictEqual(h.data(), 128);
assert.strictEqual(h.success(), true);
});
});

587
stream/reader.js Normal file
View File

@ -0,0 +1,587 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 Exception from "./exception.js";
import * as subscribe from "./subscribe.js";
export class Buffer {
/**
* constructor
*
* @param {Uint8Array} buffer Array buffer
* @param {function} depleted Callback that will be called when the buffer
* is depleted
*/
constructor(buffer, depleted) {
this.buffer = buffer;
this.used = 0;
this.onDepleted = depleted;
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
* @param {number} maxLen Max search length
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
searchBuffer(byteData, maxLen) {
let searchLen = this.remains();
if (searchLen > maxLen) {
searchLen = maxLen;
}
for (let i = 0; i < searchLen; i++) {
if (this.buffer[i + this.used] !== byteData) {
continue;
}
return i;
}
return -1;
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
indexOf(byteData) {
return this.searchBuffer(byteData, this.remains());
}
/**
* Return how many bytes in the source + buffer is still available to be
* read, return 0 when reader is depleted and thus can be ditched
*
* @returns {number} Remaining size
*
*/
remains() {
return this.buffer.length - this.used;
}
/**
* Return how many bytes is still availale in the buffer.
*
* Note: This reader don't have renewable data source, so when buffer
* depletes, the reader is done
*
* @returns {number} Remaining size
*
*/
buffered() {
return this.remains();
}
/**
* Export max n bytes from current buffer
*
* @param {number} n suggested max byte length, set to 0 to refresh buffer
* if current buffer is deplated
*
* @returns {Uint8Array} Exported data
*
* @throws {Exception} When reader has been depleted
*
*/
export(n) {
let remain = this.remains();
if (remain <= 0) {
throw new Exception("Reader has been depleted", false);
}
if (remain > n) {
remain = n;
}
let exported = this.buffer.slice(this.used, this.used + remain);
this.used += exported.length;
if (this.remains() <= 0) {
this.onDepleted();
}
return exported;
}
}
export class Multiple {
/**
* Constructor
*
* @param {function} depleted Callback will be called when all reader is
* depleted
*
*/
constructor(depleted) {
this.reader = null;
this.depleted = depleted;
this.subscribe = new subscribe.Subscribe();
this.closed = false;
}
/**
* Add new reader as sub reader
*
* @param {Buffer} reader
* @param {function} depleted Callback that will be called when given reader
* is depleted
*
* @throws {Exception} When the reader is closed
*
*/
feed(reader, depleted) {
if (this.closed) {
throw new Exception("Reader is closed", false);
}
if (this.reader === null && this.subscribe.pendings() <= 0) {
this.reader = {
reader: reader,
depleted: depleted,
};
return;
}
this.subscribe.resolve({
reader: reader,
depleted: depleted,
});
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
* @param {number} maxLen Max search length
*
* @returns {number} Return number >= 0 when found, -1 when not
*
*/
searchBuffer(byteData, maxLen) {
if (this.reader === null) {
return -1;
}
return this.reader.reader.searchBuffer(byteData, maxLen);
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
indexOf(byteData) {
return this.searchBuffer(byteData, this.buffered());
}
/**
* Return how many bytes still available in the buffer (How many bytes of
* buffer is left for read before reloading from data source)
*
* @returns {number} How many bytes left in the current buffer
*/
buffered() {
if (this.reader == null) {
return 0;
}
return this.reader.reader.buffered();
}
/**
* close current reading
*
*/
close() {
return this.closeWithReason("Reader is closed");
}
/**
* close current reading
*
* @param {string} reason Reason
*
*/
closeWithReason(reason) {
if (this.closed) {
return;
}
this.closed = true;
this.subscribe.reject(new Exception(reason, false));
this.subscribe.disable(reason);
}
/**
* Export max n bytes from current buffer
*
* @param {number} n suggested max byte length, set to 0 to refresh buffer
* if current buffer is deplated
*
* @returns {Uint8Array} Exported data
*
*/
async export(n) {
for (;;) {
if (this.reader !== null) {
let exported = await this.reader.reader.export(n);
if (this.reader.reader.remains() <= 0) {
this.reader.depleted();
this.reader = null;
}
return exported;
}
this.depleted(this);
this.reader = await this.subscribe.subscribe();
}
}
}
export class Reader {
/**
* constructor
*
* @param {Multiple} multiple Source reader
* @param {function} bufferConverter Function convert
*
*/
constructor(multiple, bufferConverter) {
this.multiple = multiple;
this.buffers = new subscribe.Subscribe();
this.bufferConverter =
bufferConverter ||
((d) => {
return d;
});
this.closed = false;
}
/**
* Add buffer into current reader
*
* @param {Uint8Array} buffer buffer to add
*
* @throws {Exception} When the reader is closed
*
*/
feed(buffer) {
if (this.closed) {
throw new Exception("Reader is closed, new data has been deined", false);
}
this.buffers.resolve(buffer);
}
async reader() {
if (this.closed) {
throw new Exception("Reader is closed, unable to read", false);
}
if (this.multiple.buffered() > 0) {
return this.multiple;
}
let self = this,
converted = await this.bufferConverter(await self.buffers.subscribe());
this.multiple.feed(new Buffer(converted, () => {}), () => {});
return this.multiple;
}
/**
* close current reading
*
*/
close() {
return this.closeWithReason("Reader is closed");
}
/**
* close current reading
*
* @param {string} reason Reason
*
*/
closeWithReason(reason) {
if (this.closed) {
return;
}
this.closed = true;
this.buffers.reject(new Exception(reason, false));
this.buffers.disable(reason);
return this.multiple.close();
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
* @param {number} maxLen Max search length
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
async searchBuffer(byteData, maxLen) {
return (await this.reader()).searchBuffer(byteData, maxLen);
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
async indexOf(byteData) {
return (await this.reader()).indexOf(byteData);
}
/**
* Return how many bytes still available in the buffer (How many bytes of
* buffer is left for read before reloading from data source)
*
* @returns {number} How many bytes left in the current buffer
*/
async buffered() {
return (await this.reader()).buffered();
}
/**
* Export max n bytes from current buffer
*
* @param {number} n suggested max byte length, set to 0 to refresh buffer
* if current buffer is deplated
*
* @returns {Uint8Array} Exported data
*
*/
async export(n) {
return (await this.reader()).export(n);
}
}
/**
* Read exactly one bytes from the reader
*
* @param {Reader} reader the source reader
*
* @returns {Uint8Array} Exported data
*
*/
export async function readOne(reader) {
for (;;) {
let d = await reader.export(1);
if (d.length <= 0) {
continue;
}
return d;
}
}
/**
* Read exactly n bytes from the reader
*
* @param {Reader} reader the source reader
* @param {number} n length to read
*
* @returns {Uint8Array} Exported data
*
*/
export async function readN(reader, n) {
let readed = 0,
result = new Uint8Array(n);
while (readed < n) {
let exported = await reader.export(n - readed);
result.set(exported, readed);
readed += exported.length;
}
return result;
}
export class Limited {
/**
* Constructor
*
* @param {Reader} reader the source reader
* @param {number} maxN max bytes to read
*
* @returns {boolean} true when the reader is completed, false otherwise
*
*/
constructor(reader, maxN) {
this.reader = reader;
this.remain = maxN;
}
/**
* Indicate whether or not the current reader is completed
*
* @returns {boolean} true when the reader is completed, false otherwise
*
*/
completed() {
return this.remain <= 0;
}
/**
* Return the index of given byte inside current available (unused) read
* buffer
*
* @param {number} byteData Target data
* @param {number} maxLen Max search length
*
* @returns {number} Return number >= 0 when found, -1 when not
*
*/
searchBuffer(byteData, maxLen) {
return this.reader.searchBuffer(
byteData,
maxLen > this.remain ? this.remain : maxLen,
);
}
/**
* Return the index of given byte inside current read buffer
*
* @param {number} byteData Target data
*
* @returns {number} Return number >= 0 when found, -1 when not
*/
indexOf(byteData) {
return this.reader.searchBuffer(byteData, this.remain);
}
/**
* Return how many bytes still available to be read
*
* @returns {number} Remaining size
*
*/
remains() {
return this.remain;
}
/**
* Return how many bytes still available in the buffer (How many bytes of
* buffer is left for read before reloading from data source)
*
* @returns {number} Remaining size
*
*/
buffered() {
let buf = this.reader.buffered();
return buf > this.remain ? this.remain : buf;
}
/**
* Export max n bytes from current buffer
*
* @param {number} n suggested max length
*
* @throws {Exception} when reading already completed
*
* @returns {Uint8Array} Exported data
*
*/
async export(n) {
if (this.completed()) {
throw new Exception("Reader already completed", false);
}
let toRead = n > this.remain ? this.remain : n,
exported = await this.reader.export(toRead);
this.remain -= exported.length;
return exported;
}
}
/**
* Read the whole Limited reader and return the result
*
* @param {Limited} limited the Limited reader
*
* @returns {Uint8Array} Exported data
*
*/
export async function readCompletely(limited) {
return await readN(limited, limited.remains());
}
/**
* Read until given byteData is reached. This function is guaranteed to spit
* out at least one byte
*
* @param {Reader} indexOfReader
* @param {number} byteData
*/
export async function readUntil(indexOfReader, byteData) {
let pos = await indexOfReader.indexOf(byteData),
buffered = await indexOfReader.buffered();
if (pos >= 0) {
return {
data: await readN(indexOfReader, pos + 1),
found: true,
};
}
if (buffered <= 0) {
let d = await readOne(indexOfReader);
return {
data: d,
found: d[0] === byteData,
};
}
return {
data: await readN(indexOfReader, buffered),
found: false,
};
}

197
stream/reader_test.js Normal file
View File

@ -0,0 +1,197 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 assert from "assert";
import * as reader from "./reader.js";
describe("Reader", () => {
it("Buffer", async () => {
let buf = new reader.Buffer(
new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
() => {},
);
let ex = buf.export(1);
assert.strictEqual(ex.length, 1);
assert.strictEqual(ex[0], 0);
assert.strictEqual(buf.remains(), 7);
ex = await reader.readCompletely(buf);
assert.strictEqual(ex.length, 7);
assert.deepStrictEqual(ex, new Uint8Array([1, 2, 3, 4, 5, 6, 7]));
assert.strictEqual(buf.remains(), 0);
});
it("Reader", async () => {
const maxTests = 3;
let IntvCount = 0,
r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
}),
expected = [
0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7,
],
feedIntv = setInterval(() => {
r.feed(Uint8Array.from(expected.slice(0, 8)));
IntvCount++;
if (IntvCount < maxTests) {
return;
}
clearInterval(feedIntv);
}, 300);
let result = [];
while (result.length < expected.length) {
result.push((await r.export(1))[0]);
}
assert.deepStrictEqual(result, expected);
});
it("readOne", async () => {
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
setTimeout(() => {
r.feed(Uint8Array.from([0, 1, 2, 3, 4, 5, 7]));
}, 100);
let rr = await reader.readOne(r);
assert.deepStrictEqual(rr, Uint8Array.from([0]));
rr = await reader.readOne(r);
assert.deepStrictEqual(rr, Uint8Array.from([1]));
});
it("readN", async () => {
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
});
setTimeout(() => {
r.feed(Uint8Array.from([0, 1, 2, 3, 4, 5, 7]));
}, 100);
let rr = await reader.readN(r, 3);
assert.deepStrictEqual(rr, Uint8Array.from([0, 1, 2]));
rr = await reader.readN(r, 3);
assert.deepStrictEqual(rr, Uint8Array.from([3, 4, 5]));
});
it("Limited", async () => {
const maxTests = 3;
let IntvCount = 0,
r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
}),
expected = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
limited = new reader.Limited(r, 10),
feedIntv = setInterval(() => {
r.feed(Uint8Array.from(expected.slice(0, 8)));
IntvCount++;
if (IntvCount < maxTests) {
return;
}
clearInterval(feedIntv);
}, 300);
let result = [];
while (!limited.completed()) {
result.push((await limited.export(1))[0]);
}
assert.strictEqual(limited.completed(), true);
assert.deepStrictEqual(result, expected);
});
it("readCompletely", async () => {
const maxTests = 3;
let IntvCount = 0,
r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
}),
expected = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
limited = new reader.Limited(r, 10),
feedIntv = setInterval(() => {
r.feed(Uint8Array.from(expected.slice(0, 8)));
IntvCount++;
if (IntvCount < maxTests) {
return;
}
clearInterval(feedIntv);
}, 300);
let result = await reader.readCompletely(limited);
assert.strictEqual(limited.completed(), true);
assert.deepStrictEqual(result, Uint8Array.from(expected));
});
it("readUntil", async () => {
const maxTests = 3;
let IntvCount = 0,
r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
return data;
}),
sample = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1],
expected1 = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
expected2 = new Uint8Array([0, 1]),
limited = new reader.Limited(r, 10),
feedIntv = setInterval(() => {
r.feed(Uint8Array.from(sample));
IntvCount++;
if (IntvCount < maxTests) {
return;
}
clearInterval(feedIntv);
}, 300);
let result = await reader.readUntil(limited, 7);
assert.strictEqual(limited.completed(), false);
assert.deepStrictEqual(result.data, expected1);
assert.deepStrictEqual(result.found, true);
result = await reader.readUntil(limited, 7);
assert.strictEqual(limited.completed(), true);
assert.deepStrictEqual(result.data, expected2);
assert.deepStrictEqual(result.found, false);
});
});

225
stream/sender.js Normal file
View File

@ -0,0 +1,225 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 Exception from "./exception.js";
import * as subscribe from "./subscribe.js";
export class Sender {
/**
* constructor
*
* @param {function} sender Underlaying sender
* @param {integer} maxSegSize The size of max data segment
* @param {integer} bufferFlushDelay Buffer flush delay
* @param {integer} maxBufferedRequests Buffer flush delay
*
*/
constructor(sender, maxSegSize, bufferFlushDelay, maxBufferedRequests) {
this.sender = sender;
this.maxSegSize = maxSegSize;
this.subscribe = new subscribe.Subscribe();
this.sendingPoc = this.sending();
this.sendDelay = null;
this.bufferFlushDelay = bufferFlushDelay;
this.maxBufferedRequests = maxBufferedRequests;
this.buffer = new Uint8Array(maxSegSize);
this.bufferUsed = 0;
this.bufferReq = 0;
}
/**
* Set the send delay of current sender
*
* @param {integer} newDelay the new delay
*
*/
setDelay(newDelay) {
this.bufferFlushDelay = newDelay;
}
/**
* Sends data to the this.sender
*
* @param {Uint8Array} data to send
* @param {Array<function>} callbacks to call to return send result
*
*/
async sendData(data, callbacks) {
try {
await this.sender(data);
for (let i in callbacks) {
callbacks[i].resolve();
}
} catch (e) {
for (let i in callbacks) {
callbacks[i].reject(e);
}
}
}
/**
* Append data to the end of internal buffer
*
* @param {Uint8Array} data data to add
*
* @returns {integer} How many bytes of data is added
*
*/
appendBuffer(data) {
const remainSize = this.buffer.length - this.bufferUsed,
appendLength = data.length > remainSize ? remainSize : data.length;
this.buffer.set(data.slice(0, appendLength), this.bufferUsed);
this.bufferUsed += appendLength;
return appendLength;
}
/**
* Export current buffer and reset it to empty
*
* @returns {Uint8Array} Exported buffer
*
*/
exportBuffer() {
const buffer = this.buffer.slice(0, this.bufferUsed);
this.bufferUsed = 0;
this.bufferedRequests = 0;
return buffer;
}
/**
* Sender proc
*
*/
async sending() {
let callbacks = [];
for (;;) {
const fetched = await this.subscribe.subscribe();
// Force flush?
if (fetched === true) {
if (this.bufferUsed <= 0) {
continue;
}
await this.sendData(this.exportBuffer(), callbacks);
callbacks = [];
continue;
}
callbacks.push({
resolve: fetched.resolve,
reject: fetched.reject,
});
// Add data to buffer and maybe flush when the buffer is full
let currentSendDataLen = 0;
while (fetched.data.length > currentSendDataLen) {
const sentLen = this.appendBuffer(
fetched.data.slice(currentSendDataLen, fetched.data.length),
);
// Buffer not full, wait for the force flush
if (this.buffer.length > this.bufferUsed) {
break;
}
currentSendDataLen += sentLen;
await this.sendData(this.exportBuffer(), callbacks);
callbacks = [];
}
}
}
/**
* Clear everything
*
*/
close() {
if (this.sendDelay !== null) {
clearTimeout(this.sendDelay);
this.sendDelay = null;
}
this.buffered = null;
this.bufferUsed = 0;
this.bufferedRequests = 0;
this.subscribe.reject(new Exception("Sender has been cleared", false));
this.subscribe.disable();
this.sendingPoc.catch(() => {});
}
/**
* Send data
*
* @param {Uint8Array} data data to send
*
* @throws {Exception} when sending has been cancelled
*
* @returns {Promise} will be resolved when the data is send and will be
* rejected when the data is not
*
*/
send(data) {
let delayCleared = false;
if (this.sendDelay !== null) {
clearTimeout(this.sendDelay);
this.sendDelay = null;
delayCleared = true;
}
const self = this;
return new Promise((resolve, reject) => {
self.subscribe.resolve({
data: data,
resolve: resolve,
reject: reject,
});
if (self.bufferedRequests >= self.maxBufferedRequests) {
self.bufferedRequests = 0;
self.subscribe.resolve(true);
return;
}
if (delayCleared) {
self.bufferedRequests++;
}
self.sendDelay = setTimeout(() => {
self.sendDelay = null;
self.bufferedRequests = 0;
self.subscribe.resolve(true);
}, self.bufferFlushDelay);
});
}
}

121
stream/sender_test.js Normal file
View File

@ -0,0 +1,121 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 assert from "assert";
import * as sender from "./sender.js";
describe("Sender", () => {
function generateTestData(size) {
let d = new Uint8Array(size);
for (let i = 0; i < d.length; i++) {
d[i] = i % 256;
}
return d;
}
it("Send", async () => {
const maxSegSize = 64;
let result = [];
let sd = new sender.Sender(
(rawData) => {
return new Promise((resolve) => {
setTimeout(() => {
for (let i in rawData) {
result.push(rawData[i]);
}
resolve();
}, 5);
});
},
maxSegSize,
300,
3,
);
let expected = generateTestData(maxSegSize * 16);
sd.send(expected);
let sendCompleted = new Promise((resolve) => {
let timer = setInterval(() => {
if (result.length < expected.length) {
return;
}
clearInterval(timer);
timer = null;
resolve();
}, 100);
});
await sendCompleted;
assert.deepStrictEqual(new Uint8Array(result), expected);
});
it("Send (Multiple calls)", async () => {
const maxSegSize = 64;
let result = [];
let sd = new sender.Sender(
(rawData) => {
return new Promise((resolve) => {
setTimeout(() => {
for (let i in rawData) {
result.push(rawData[i]);
}
resolve();
}, 10);
});
},
maxSegSize,
300,
100,
);
let expectedSingle = generateTestData(maxSegSize * 2),
expectedLen = expectedSingle.length * 16,
expected = new Uint8Array(expectedLen);
for (let i = 0; i < expectedLen; i += expectedSingle.length) {
expected.set(expectedSingle, i);
}
for (let i = 0; i < expectedLen; i += expectedSingle.length) {
setTimeout(() => {
sd.send(expectedSingle);
}, 100);
}
let sendCompleted = new Promise((resolve) => {
let timer = setInterval(() => {
if (result.length < expectedLen) {
return;
}
clearInterval(timer);
timer = null;
resolve();
}, 100);
});
await sendCompleted;
assert.deepStrictEqual(new Uint8Array(result), expected);
});
});

363
stream/stream.js Normal file
View File

@ -0,0 +1,363 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as common from "./common.js";
import Exception from "./exception.js";
import * as header from "./header.js";
import * as reader from "./reader.js";
import * as sender from "./sender.js";
export class Sender {
/**
* constructor
*
* @param {number} id ID of the stream
* @param {sender.Sender} sd The data sender
*
*/
constructor(id, sd) {
this.id = id;
this.sender = sd;
this.closed = false;
}
/**
* Sends data to remote
*
* @param {number} marker binary marker
* @param {Uint8Array} data data to be sent
*
* @throws {Exception} When the sender already been closed
*
*/
send(marker, data) {
if (this.closed) {
throw new Exception(
"Sender already been closed. No data can be send",
false,
);
}
let reqHeader = new header.Header(header.STREAM),
stHeader = new header.Stream(0, 0),
d = new Uint8Array(data.length + 3);
reqHeader.set(this.id);
stHeader.set(marker, data.length);
d[0] = reqHeader.value();
d.set(stHeader.buffer(), 1);
d.set(data, 3);
return this.sender.send(d);
}
/**
* Sends data to remote, if the data is too long, it will be separated into
* different stream requests
*
* @param {number} marker binary marker
* @param {Uint8Array} data data to be sent
*
* @throws {Exception} When the sender already been closed
*
*/
async sendData(marker, data) {
if (this.closed) {
throw new Exception(
"Sender already been closed. No data can be send",
false,
);
}
let dataSeg = common.separateBuffer(data, header.STREAM_MAX_LENGTH),
reqHeader = new header.Header(header.STREAM);
reqHeader.set(this.id);
for (let i in dataSeg) {
let stHeader = new header.Stream(0, 0),
d = new Uint8Array(dataSeg[i].length + 3);
stHeader.set(marker, dataSeg[i].length);
d[0] = reqHeader.value();
d.set(stHeader.buffer(), 1);
d.set(dataSeg[i], 3);
await this.sender.send(d);
}
}
/**
* Send stream signals
*
* @param {number} signal Signal value
*
* @throws {Exception} When the sender already been closed
*
*/
signal(signal) {
if (this.closed) {
throw new Exception(
"Sender already been closed. No signal can be send",
false,
);
}
let reqHeader = new header.Header(signal);
reqHeader.set(this.id);
return this.sender.send(new Uint8Array([reqHeader.value()]));
}
/**
* Send close signal and close current sender
*
*/
close() {
if (this.closed) {
return;
}
let r = this.signal(header.CLOSE);
this.closed = true;
return r;
}
}
export class InitialSender {
/**
* constructor
*
* @param {number} id ID of the stream
* @param {number} commandID ID of the command
* @param {sender.Sender} sd The data sender
*
*/
constructor(id, commandID, sd) {
this.id = id;
this.command = commandID;
this.sender = sd;
}
/**
* Return how large the data can be
*
* @returns {number} Max data size
*
*/
static maxDataLength() {
return header.InitialStream.maxDataSize();
}
/**
* Sends data to remote
*
* @param {Uint8Array} data data to be sent
*
*/
send(data) {
let reqHeader = new header.Header(header.STREAM),
stHeader = new header.InitialStream(0, 0),
d = new Uint8Array(data.length + 3);
reqHeader.set(this.id);
stHeader.set(this.command, data.length, true);
d[0] = reqHeader.value();
d.set(stHeader.buffer(), 1);
d.set(data, 3);
return this.sender.send(d);
}
}
export class Stream {
/**
* constructor
*
* @param {number} id ID of the stream
*
*/
constructor(id) {
this.id = id;
this.command = null;
this.isInitializing = false;
this.isShuttingDown = false;
}
/**
* Returns whether or not current stream is running
*
* @returns {boolean} True when it's running, false otherwise
*
*/
running() {
return this.command !== null;
}
/**
* Returns whether or not current stream is initializing
*
* @returns {boolean} True when it's initializing, false otherwise
*
*/
initializing() {
return this.isInitializing;
}
/**
* Unsets current stream
*
*/
clear() {
this.command = null;
this.isInitializing = false;
this.isShuttingDown = false;
}
/**
* Request the stream for a new command
*
* @param {number} commandID Command ID
* @param {function} commandBuilder Function that returns a command
* @param {sender.Sender} sd Data sender
*
* @throws {Exception} when stream already running
*
*/
run(commandID, commandBuilder, sd) {
if (this.running()) {
throw new Exception(
"Stream already running, cannot accept new commands",
false,
);
}
this.isInitializing = true;
this.command = commandBuilder(new Sender(this.id, sd));
return this.command.run(new InitialSender(this.id, commandID, sd));
}
/**
* Called when initialization respond has been received
*
* @param {header.InitialStream} streamInitialHeader Stream Initial header
*
* @throws {Exception} When the stream is not running, or been shutting down
*
*/
initialize(hd) {
if (!this.running()) {
throw new Exception(
"Cannot initialize a stream that is not running",
false,
);
}
if (this.isShuttingDown) {
throw new Exception(
"Cannot initialize a stream that is about to shutdown",
false,
);
}
this.command.initialize(hd);
if (!hd.success()) {
this.clear();
return;
}
this.isInitializing = false;
}
/**
* Called when Stream data has been received
*
* @param {header.Stream} streamHeader Stream header
* @param {reader.Limited} rd Data reader
*
* @throws {Exception} When the stream is not running, or shutting down
*
*/
tick(streamHeader, rd) {
if (!this.running()) {
throw new Exception("Cannot tick a stream that is not running", false);
}
if (this.isShuttingDown) {
throw new Exception(
"Cannot tick a stream that is about to shutdown",
false,
);
}
return this.command.tick(streamHeader, rd);
}
/**
* Called when stream close request has been received
*
* @throws {Exception} When the stream is not running, or shutting down
*
*/
close() {
if (!this.running()) {
throw new Exception("Cannot close a stream that is not running", false);
}
if (this.isShuttingDown) {
throw new Exception(
"Cannot close a stream that is about to shutdown",
false,
);
}
this.isShuttingDown = true;
this.command.close();
}
/**
* Called when stream completed respond has been received
*
* @throws {Exception} When stream isn't running, or not shutting down
*
*/
completed() {
if (!this.running()) {
throw new Exception("Cannot close a stream that is not running", false);
}
if (!this.isShuttingDown) {
throw new Exception(
"Can't complete current stream because Close " +
"signal is not received",
false,
);
}
this.command.completed();
this.clear();
}
}

436
stream/streams.js Normal file
View File

@ -0,0 +1,436 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 * as common from "./common.js";
import Exception from "./exception.js";
import * as header from "./header.js";
import * as reader from "./reader.js";
import * as sender from "./sender.js";
import * as stream from "./stream.js";
export const ECHO_FAILED = -1;
export class Requested {
/**
* constructor
*
* @param {stream.Stream} stream The selected stream
* @param {any} result Result of the run
*
*/
constructor(stream, result) {
this.stream = stream;
this.result = result;
}
}
export class Streams {
/**
* constructor
*
* @param {reader.Reader} reader The data reader
* @param {sender.Sender} sender The data sender
* @param {object} config Configuration
*/
constructor(reader, sender, config) {
this.reader = reader;
this.sender = sender;
this.config = config;
this.echoTimer = null;
this.lastEchoTime = null;
this.lastEchoData = null;
this.stop = false;
this.streams = [];
for (let i = 0; i <= header.HEADER_MAX_DATA; i++) {
this.streams.push(new stream.Stream(i));
}
}
/**
* Starts stream proccessing
*
* @returns {Promise<true>} When service is completed
*
* @throws {Exception} When the process already started
*
*/
async serve() {
if (this.echoTimer !== null) {
throw new Exception("Already started", false);
}
this.echoTimer = setInterval(() => {
this.sendEcho();
}, this.config.echoInterval);
this.stop = false;
this.sendEcho();
let ee = null;
while (!this.stop && ee === null) {
try {
await this.tick();
} catch (e) {
if (!e.temporary) {
ee = e;
}
}
}
this.clear(ee);
if (ee !== null) {
throw new Exception("Streams is closed: " + ee, false);
}
}
/**
* Clear current proccess
*
* @param {Exception} e An error caused this clear. Null when no error
*
*/
clear(e) {
if (this.stop) {
return;
}
this.stop = true;
if (this.echoTimer != null) {
clearInterval(this.echoTimer);
this.echoTimer = null;
}
for (let i in this.streams) {
if (!this.streams[i].running()) {
continue;
}
try {
this.streams[i].close();
} catch (e) {
// Do nothing
}
try {
this.streams[i].completed();
} catch (e) {
//Do nothing
}
}
try {
this.sender.close();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
try {
this.reader.close();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
this.config.cleared(e);
}
/**
* Request remote to pause stream sending
*
*/
pause() {
let pauseHeader = header.header(header.CONTROL);
pauseHeader.set(1);
return this.sender.send(
new Uint8Array([pauseHeader.value(), header.CONTROL_PAUSESTREAM]),
);
}
/**
* Request remote to resume stream sending
*
*/
resume() {
let pauseHeader = header.header(header.CONTROL);
pauseHeader.set(1);
return this.sender.send(
new Uint8Array([pauseHeader.value(), header.CONTROL_RESUMESTREAM]),
);
}
/**
* Request stream for given command
*
* @param {number} commandID Command ID
* @param {function} commandBuilder Command builder
*
* @returns {Requested} The result of the stream command
*
*/
request(commandID, commandBuilder) {
try {
for (let i in this.streams) {
if (this.streams[i].running()) {
continue;
}
return new Requested(
this.streams[i],
this.streams[i].run(commandID, commandBuilder, this.sender),
);
}
throw new Exception("No stream is currently available", true);
} catch (e) {
throw new Exception("Stream request has failed: " + e, true);
}
}
/**
* Send echo request
*
*/
sendEcho() {
let echoHeader = header.header(header.CONTROL),
randomNum = new Uint8Array(common.getRands(8, 0, 255));
echoHeader.set(randomNum.length - 1);
randomNum[0] = echoHeader.value();
randomNum[1] = header.CONTROL_ECHO;
this.sender.send(randomNum).then(() => {
if (this.lastEchoTime !== null || this.lastEchoData !== null) {
this.lastEchoTime = null;
this.lastEchoData = null;
this.config.echoUpdater(ECHO_FAILED);
}
this.lastEchoTime = new Date();
this.lastEchoData = randomNum.slice(2, randomNum.length);
});
}
/**
* handle received control request
*
* @param {reader.Reader} rd The reader
*
*/
async handleControl(rd) {
let controlType = await reader.readOne(rd),
delay = 0,
echoBytes = null;
switch (controlType[0]) {
case header.CONTROL_ECHO:
echoBytes = await reader.readCompletely(rd);
if (this.lastEchoTime === null || this.lastEchoData === null) {
return;
}
if (this.lastEchoData.length !== echoBytes.length) {
return;
}
for (let i in this.lastEchoData) {
if (this.lastEchoData[i] == echoBytes[i]) {
continue;
}
this.lastEchoTime = null;
this.lastEchoData = null;
this.config.echoUpdater(ECHO_FAILED);
return;
}
delay = new Date().getTime() - this.lastEchoTime.getTime();
if (delay < 0) {
delay = 0;
}
this.lastEchoTime = null;
this.lastEchoData = null;
this.config.echoUpdater(delay);
return;
}
await reader.readCompletely(rd);
throw new Exception("Unknown control signal: " + controlType);
}
/**
* handle received stream respond
*
* @param {header.Header} hd The header
* @param {reader.Reader} rd The reader
*
* @throws {Exception} when given stream is not running
*
*/
async handleStream(hd, rd) {
if (hd.data() >= this.streams.length) {
return;
}
let stream = this.streams[hd.data()];
if (!stream.running()) {
// WARNING: Connection must be reset at this point because we cannot
// determine how many bytes to read
throw new Exception(
'Remote is requesting for stream "' +
hd.data() +
'" which is not running',
false,
);
}
let initialHeaderBytes = await reader.readN(rd, 2);
// WARNING: It's the stream's responsibility to ensure stream data is
// completely readed before return
if (stream.initializing()) {
let streamHeader = new header.InitialStream(
initialHeaderBytes[0],
initialHeaderBytes[1],
);
return stream.initialize(streamHeader);
}
let streamHeader = new header.Stream(
initialHeaderBytes[0],
initialHeaderBytes[1],
),
streamReader = new reader.Limited(rd, streamHeader.length());
let tickResult = await stream.tick(streamHeader, streamReader);
await reader.readCompletely(streamReader);
return tickResult;
}
/**
* handle received close respond
*
* @param {header.Header} hd The header
*
* @throws {Exception} when given stream is not running
*
*/
async handleClose(hd) {
if (hd.data() >= this.streams.length) {
return;
}
let stream = this.streams[hd.data()];
if (!stream.running()) {
// WARNING: Connection must be reset at this point because we cannot
// determine how many bytes to read
throw new Exception(
'Remote is requesting for stream "' +
hd.data() +
'" to be closed, but the stream is not running',
false,
);
}
let cResult = await stream.close();
let completedHeader = new header.Header(header.COMPLETED);
completedHeader.set(hd.data());
this.sender.send(new Uint8Array([completedHeader.value()]));
return cResult;
}
/**
* handle received close respond
*
* @param {header.Header} hd The header
*
* @throws {Exception} when given stream is not running
*
*/
async handleCompleted(hd) {
if (hd.data() >= this.streams.length) {
return;
}
let stream = this.streams[hd.data()];
if (!stream.running()) {
// WARNING: Connection must be reset at this point because we cannot
// determine how many bytes to read
throw new Exception(
'Remote is requesting for stream "' +
hd.data() +
'" to be completed, but the stream is not running',
false,
);
}
return stream.completed();
}
/**
* Main proccess loop
*
* @throws {Exception} when encountered an unknown header
*/
async tick() {
let headerBytes = await reader.readOne(this.reader),
hd = new header.Header(headerBytes[0]);
switch (hd.type()) {
case header.CONTROL:
return this.handleControl(new reader.Limited(this.reader, hd.data()));
case header.STREAM:
return this.handleStream(hd, this.reader);
case header.CLOSE:
return this.handleClose(hd);
case header.COMPLETED:
return this.handleCompleted(hd);
default:
throw new Exception("Unknown header", false);
}
}
}

20
stream/streams_test.js Normal file
View File

@ -0,0 +1,20 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
describe("Streams", () => {
it("Header", () => {});
});

129
stream/subscribe.js Normal file
View File

@ -0,0 +1,129 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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 Exception from "./exception.js";
const typeReject = 0;
const typeResolve = 1;
export class Subscribe {
/**
* constructor
*
*/
constructor() {
this.res = null;
this.rej = null;
this.pending = [];
this.disabled = null;
}
/**
* Returns how many resolve/reject in the pending
*/
pendings() {
return (
this.pending.length + (this.rej !== null || this.res !== null ? 1 : 0)
);
}
/**
* Resolve the subscribe waiter
*
* @param {any} d Resolve data which will be send to the subscriber
*/
resolve(d) {
if (this.res !== null) {
this.res(d);
return;
}
this.pending.push([typeResolve, d]);
}
/**
* Reject the subscribe waiter
*
* @param {any} e Error message that will be send to the subscriber
*
*/
reject(e) {
if (this.rej !== null) {
this.rej(e);
return;
}
this.pending.push([typeReject, e]);
}
/**
* Waiting and receive subscribe data
*
* @returns {Promise<any>} Data receiver
*
*/
subscribe() {
if (this.pending.length > 0) {
let p = this.pending.shift();
switch (p[0]) {
case typeReject:
throw p[1];
case typeResolve:
return p[1];
default:
throw new Exception("Unknown pending type", false);
}
}
if (this.disabled) {
throw new Exception(this.disabled, false);
}
let self = this;
return new Promise((resolve, reject) => {
self.res = (d) => {
self.res = null;
self.rej = null;
resolve(d);
};
self.rej = (e) => {
self.res = null;
self.rej = null;
reject(e);
};
});
}
/**
* Disable current subscriber when all internal data is readed
*
* @param {string} reason Reason of the disable
*
*/
disable(reason) {
this.disabled = reason;
}
}

View File

@ -370,7 +370,7 @@ function startApp(rootEl) {
break;
case 403:
this.authErr = "Authentication échoué. Mauvais mot de passe?";
this.authErr = "Authentication has failed. Wrong passphrase?";
break;
default:
@ -378,7 +378,7 @@ function startApp(rootEl) {
"Unexpected backend query status: " + result.result;
}
} catch (e) {
this.authErr = "Authentification impossible: " + e;
this.authErr = "Unable to authenticate: " + e;
}
},
updateTabTitleInfo(tabs, updated) {

View File

@ -68,20 +68,21 @@
@updated="tabUpdated"
>
<div id="home-content-wrap">
<h1>Bienvenue sur SSHWIFTY</h1>
<h1>Hi, this is Sshwifty</h1>
<p>
Un client web SSH open source permettant de se connecter à un serveur sans terminal.
An Open Source Web SSH Client that enables you to connect to SSH
servers without downloading any additional software.
</p>
<p>
Cliquer sur le
To get started, click the
<span
id="home-content-connect"
class="icon icon-plus1"
@click="showConnectWindow"
></span>
en haut à gauche.
icon near the top left corner.
</p>
<div v-if="serverMessage.length > 0">

View File

@ -20,7 +20,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Garage Web SSH Client</title>
<title>Sshwifty Web SSH Client</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
@ -28,21 +28,23 @@
<div id="landing-message">
<div id="landing-message-logo"></div>
<h1 id="landing-message-title">Chargement de Sshwifty</h1>
<h1 id="landing-message-title">Loading Sshwifty</h1>
<div id="landing-message-info">
<p>
Ca charge!
Client is currently being loaded. Should only take a few seconds,
please wait
</p>
<noscript>
<p>
Il faut activer Javascript pour que ça marche... :)
Also, surely you smart people knows that application such like
this one require JavaScript to run :)
</p>
</noscript>
<!-- <p class="copy copy-first">
<p class="copy copy-first">
Copyright &copy; 2019-2023 Ni Rui &lt;ranqus@gmail.com&gt;
</p> -->
<!-- <p class="copy">
</p>
<p class="copy">
<a href="https://github.com/nirui/sshwifty" target="blank">
Source code
</a>
@ -54,7 +56,7 @@
<a href="/sshwifty/assets/README.md" target="blank"> Readme </a>
<a href="/sshwifty/assets/LICENSE.md" target="blank"> License </a>
</p> -->
</p>
</div>
</div>
</div>

View File

@ -24,7 +24,7 @@
<div v-else id="app-loading-error">&times;</div>
<h1 v-if="error.length <= 0" id="app-loading-title">
Chargement
Preparing client application
</h1>
<h1 v-else id="app-loading-title" class="error">
{{ error }}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -25,7 +25,7 @@
@display="$emit('display', $event)"
>
<div id="connect-frame">
<h1 class="window-title">On se connecte à</h1>
<h1 class="window-title">Establish connection with</h1>
<slot v-if="inputting"></slot>

View File

@ -31,7 +31,7 @@
:class="{ disabled: working || cancelled }"
@click="cancel()"
>
Abandonner
Cancel
</a>
<div
@ -809,7 +809,7 @@ export default {
} catch (e) {
this.current.submitting = false;
alert("Erreur de connection: " + e);
alert("Submission has failed: " + e);
process.env.NODE_ENV === "development" && console.trace(e);

View File

@ -125,6 +125,24 @@ const termDefaultFontSize = 16;
const termMinFontSize = 8;
const termMaxFontSize = 36;
function webglSupported() {
try {
if (typeof window !== "object") {
return false;
}
if (typeof window.WebGLRenderingContext !== "function") {
return false;
}
if (typeof window.WebGL2RenderingContext !== "function") {
return false;
}
return document.createElement('canvas').getContext('webgl') &&
document.createElement('canvas').getContext('webgl2');
} catch(e) {
}
return false;
}
class Term {
constructor(control) {
const resizeDelayInterval = 500;
@ -230,16 +248,11 @@ class Term {
this.term.loadAddon(this.fit);
this.term.loadAddon(new WebLinksAddon());
this.term.loadAddon(new Unicode11Addon());
if (() => {
try {
return !!window.WebGLRenderingContext &&
document.createElement('canvas').getContext('webgl');
} catch(e) {
return false;
try {
if (webglSupported()) {
this.term.loadAddon(new WebglAddon());
}
}) {
this.term.loadAddon(new WebglAddon());
}
} catch(e) {}
this.term.unicode.activeVersion = '11';
this.refit();
}

7
widgets/busy.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" height="150" width="150">
<path d="M29.49 50.27a.912.912 0 0 0-.13.012 1.125 1.141 0 0 0-.364.117l-23 12.035a1.097 1.112 0 0 0-.589 1.005v2.958a.314.319 0 0 0 0 .012 1.092 1.108 0 0 0 .577.97l20.897 11.708a.412.418 0 0 1 .212.372V91a1.118 1.118 0 0 0 1.111 1.11h1.311a1.105 1.12 0 0 0 1.086-1.11V78.163c0-.258.168-.416.424-.416h12.406c.255 0 .415.16.415.416v12.838a1.119 1.119 0 0 0 1.111 1.11h1.308a1.108 1.124 0 0 0 1.085-1.11V68c0-.175.038-.247.084-.298a.459.465 0 0 1 .248-.121.471.478 0 0 1 .283.012c.056.025.113.077.178.205.006.013.006.006.012.019a.314.319 0 0 0 0 .012l9.574 21.25c.195.431.613.667 1.015.66a1.105 1.12 0 0 0 .992-.694l6.031-15.204c.095-.24.203-.263.403-.263s.295.024.39.26l6.022 15.208c.17.451.581.708.99.717a1.116 1.132 0 0 0 1.039-.67l9.56-21.264c.073-.16.131-.201.191-.228a.437.443 0 0 1 .27-.013.459.465 0 0 1 .249.122c.047.049.081.122.083.298v22.988a.314.319 0 0 0 0 .012 1.105 1.12 0 0 0 1.11 1.1h1.312a1.094 1.11 0 0 0 1.095-1.1.314.319 0 0 0 0-.012V65.11a1.094 1.11 0 0 0-1.095-1.1h-4.512a1.092 1.108 0 0 0-1.005.633.314.319 0 0 0-.01 0c-2.652 5.835-5.294 11.682-7.945 17.515-.102.225-.207.257-.402.252-.194-.004-.286-.049-.377-.276l-5.95-14.867a1.1 1.116 0 0 0-1.016-.717 1.116 1.132 0 0 0-1.027.717L59.18 82.134c-.094.232-.186.271-.377.276-.197.005-.29-.024-.392-.252L50.47 64.643a1.108 1.124 0 0 0-1.004-.634h-4.51a1.103 1.119 0 0 0-1.11 1.1v8.622c0 .257-.169.443-.415.443H31.025c-.248 0-.424-.186-.424-.444v-8.62a1.097 1.112 0 0 0-1.086-1.1h-1.311a1.103 1.119 0 0 0-1.111 1.1v9.149a.752.752 0 0 1-.27.526c-.18.133-.205.138-.332.071a.314.319 0 0 0-.013 0 .314.319 0 0 0-.047-.024.314.319 0 0 0-.036-.012c-5.772-3.016-11.524-6.302-17.304-9.386a.434.44 0 0 1-.212-.265 1.43 1.45 0 0 1 .012-.441.959.972 0 0 1 .223-.324 1.09 1.106 0 0 1 .164-.12c.077-.024.157-.054.226-.095 6.808-3.626 13.682-7.116 20.504-10.694a1.103 1.119 0 0 0 .593-.99V51.39a1.105 1.12 0 0 0-1.111-1.125zm63.89 13.744a1.108 1.124 0 0 0-1.096 1.126v25.857a1.116 1.132 0 0 0 1.108 1.116h1.298a1.106 1.122 0 0 0 1.1-1.116V78.163c0-.255.172-.417.426-.417h10.823a1.103 1.119 0 0 0 1.099-1.126v-1.342a1.097 1.112 0 0 0-1.1-1.1H96.216c-.247 0-.426-.188-.426-.444V68c0-.255.172-.42.426-.42H115.6c.254 0 .414.163.414.42v22.997c.005.606.488 1.108 1.085 1.116a.314.319 0 0 0 .01 0h1.299a1.114 1.13 0 0 0 1.11-1.116V68c0-.259.161-.42.416-.42h7.14a.41.41 0 0 1 .355.192l7.615 12.249a.393.398 0 0 1 .07.215v10.76a1.111 1.127 0 0 0 1.087 1.116h1.307a1.111 1.127 0 0 0 1.088-1.115V80.236c0-.07.022-.137.07-.215l8.867-14.296c.454-.727-.072-1.708-.92-1.711h-1.501a1.1 1.116 0 0 0-.943.516l-6.967 11.205c-.1.161-.228.215-.354.215-.125 0-.251-.055-.352-.215L129.52 64.53a1.094 1.11 0 0 0-.921-.516c-11.732 0-23.473-.007-35.206 0zM3.411 70.706a.631.631 0 0 0-.107.012 1.094 1.11 0 0 0-1.005 1.1v1.294a1.12 1.136 0 0 0 .582.983l16.998 9.481.012.012a.314.319 0 0 0 .037.025.314.319 0 0 0 .047.037c.146.08.223.202.223.36v1.96c0 .16-.074.297-.212.375a.314.319 0 0 0-.071.049L2.869 96.307a1.1 1.116 0 0 0-.569.969v1.354a.314.319 0 0 0 0 .013c.007.841.939 1.367 1.655.943l18.699-10.98a1.099 1.114 0 0 0 .543-.972v-5.207a1.105 1.12 0 0 0-.567-.972C16.4 77.922 3.944 70.85 3.944 70.85a.314.319 0 0 0-.014 0 1.083 1.098 0 0 0-.518-.143z" fill="none" stroke="#e9a" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="20 30 10 40" stroke-dashoffset="-100">
<animate restart="always" repeatCount="indefinite" dur="3s" to="100" from="-100" attributeName="stroke-dashoffset" begin="0s"/>
<animate id="l1" dur="5s" to="40 10 50 0" from="20 30 10 40" attributeName="stroke-dasharray" begin="0s;l2.end" calcMode="paced"/>
<animate id="l2" dur="5s" to="20 30 10 40" from="40 10 50 0" attributeName="stroke-dasharray" begin="l1.end" calcMode="paced"/>
</path>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

316
widgets/chart.vue Normal file
View File

@ -0,0 +1,316 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<svg xmlns="http://www.w3.org/2000/svg">
<slot />
</svg>
</template>
<script>
/* eslint vue/attribute-hyphenation: 0 */
const XMLNG = "http://www.w3.org/2000/svg";
const XMLNS = "http://www.w3.org/2000/xmlns/";
const XMLNGLink = "http://www.w3.org/1999/xlink";
class Data {
constructor(data) {
this.data = data;
this.max = this.getMax(data);
}
setMax(max) {
this.max = this.max > max ? this.max : max;
}
getMax(data) {
let max = 0;
for (let i in data) {
if (data[i].data <= max) {
continue;
}
max = data[i].data;
}
return max;
}
}
class BaseDrawer {
constructor() {
this.elements = [];
}
toCellHeight(cellHeight, data, n) {
if (data.max === 0) {
return 0;
}
return (cellHeight / data.max) * n;
}
toBottomHeight(cellHeight, n) {
return cellHeight - n;
}
cellWidth(rootDim, data) {
return rootDim.width / data.data.length;
}
createEl(parent, tag, properties) {
let np = document.createElementNS(XMLNG, tag);
for (let p in properties) {
if (p.indexOf("xlink:") === 0) {
np.setAttributeNS(XMLNGLink, p, properties[p]);
} else if (p.indexOf("xmlns:") === 0) {
np.setAttributeNS(XMLNS, p, properties[p]);
} else {
np.setAttribute(p, properties[p]);
}
}
parent.appendChild(np);
this.elements.push(np);
return np;
}
removeAllEl(parent) {
for (let i in this.elements) {
parent.removeChild(this.elements[i]);
}
this.elements = [];
}
draw(parent, rootDim, data) {}
}
class BarDrawer extends BaseDrawer {
constructor(topBottomPadding) {
super();
this.topBottomPadding = topBottomPadding;
}
draw(parent, rootDim, data) {
let cellWidth = this.cellWidth(rootDim, data),
currentWidth = cellWidth / 2,
cellHalfHeight = rootDim.height - this.topBottomPadding / 2,
cellHeight = rootDim.height - this.topBottomPadding;
for (let i in data.data) {
let h = this.toCellHeight(cellHeight, data, data.data[i].data);
this.createEl(parent, "path", {
d:
"M" +
currentWidth +
"," +
Math.round(this.toBottomHeight(cellHalfHeight, h)) +
" L" +
currentWidth +
"," +
cellHalfHeight,
class: h === 0 ? "zero" : data.data[i].class,
});
currentWidth += cellWidth;
}
}
}
class UpsideDownBarDrawer extends BarDrawer {
draw(parent, rootDim, data) {
let cellWidth = this.cellWidth(rootDim, data),
currentWidth = cellWidth / 2,
padHalfHeight = this.topBottomPadding / 2,
cellHeight = rootDim.height - this.topBottomPadding;
for (let i in data.data) {
let h = this.toCellHeight(cellHeight, data, data.data[i].data);
this.createEl(parent, "path", {
d:
"M" +
currentWidth +
"," +
padHalfHeight +
" L" +
currentWidth +
"," +
(Math.round(h) + padHalfHeight),
class: h === 0 ? "zero" : data.data[i].class,
});
currentWidth += cellWidth;
}
}
}
class Chart {
constructor(el, width, height, drawer) {
this.el = el;
this.drawer = drawer;
this.group = null;
this.paths = [];
this.dim = { width, height };
this.el.setAttribute(
"viewBox",
"0 0 " +
parseInt(this.dim.width, 10) +
" " +
parseInt(this.dim.height, 10),
);
this.el.setAttribute("preserveAspectRatio", "xMidYMid meet");
}
getGroupRoot() {
if (this.group) {
return this.group;
}
this.group = document.createElementNS(XMLNG, "g");
this.el.appendChild(this.group);
return this.group;
}
draw(data, manualMax) {
let d = new Data(data);
let max = d.max;
d.setMax(manualMax);
this.drawer.removeAllEl(this.getGroupRoot());
this.drawer.draw(this.getGroupRoot(), this.dim, d);
return {
dataMax: max,
resultMax: d.max,
};
}
clear() {
this.drawer.removeAllEl();
this.el.removeChild(this.getGroupRoot());
}
}
function buildDrawer(type) {
switch (type) {
case "Bar":
return new BarDrawer(10);
case "UpsideDownBar":
return new UpsideDownBarDrawer(10);
}
return new Error("Undefined drawer: " + type);
}
export default {
props: {
values: {
type: Array,
default: () => [],
},
width: {
type: Number,
default: 0,
},
height: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 0,
},
enabled: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "",
},
},
data() {
return {
chart: null,
previousMax: 0,
};
},
watch: {
values() {
if (!this.enabled) {
return;
}
this.draw();
},
max() {
if (!this.enabled) {
return;
}
this.draw();
},
enabled(newVal) {
if (!newVal) {
return;
}
this.draw();
},
},
mounted() {
this.chart = new Chart(
this.$el,
this.width,
this.height,
buildDrawer(this.type),
);
},
beforeDestroy() {
this.chart.clear();
},
methods: {
draw() {
let r = this.chart.draw(this.values, this.max);
if (r.dataMax === this.previousMax) {
return;
}
this.$emit("max", r.dataMax);
this.previousMax = r.dataMax;
},
},
};
</script>

120
widgets/connect.css Normal file
View File

@ -0,0 +1,120 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#connect {
z-index: 999999;
top: 40px;
left: 159px;
display: none;
background: #333;
width: 700px;
}
#connect-frame {
z-index: 0;
position: relative;
}
#connect .window-frame {
max-height: calc(100vh - 40px);
overflow: auto;
}
#connect:before {
left: 30px;
background: #333;
}
@media (max-width: 1024px) {
#connect {
left: 20px;
right: 20px;
width: auto;
}
#connect:before {
left: 169px;
}
}
@media (max-width: 768px) {
#connect:before {
left: 149px;
}
}
#connect.display {
display: block;
}
#connect h1 {
padding: 15px 15px 0 15px;
margin-bottom: 10px;
color: #999;
}
#connect-close {
cursor: pointer;
color: #999;
right: 10px;
top: 20px;
}
#connect-busy-overlay {
z-index: 2;
background: #2229 url("busy.svg") center center no-repeat;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: absolute;
backdrop-filter: blur(1px);
}
#connect-warning {
padding: 20px;
font-size: 0.85em;
background: #b44;
color: #fff;
}
#connect-warning-icon {
float: left;
display: block;
margin: 5px 20px 5px 0;
}
#connect-warning-icon::after {
background: #c55;
}
#connect-warning-msg {
overflow: auto;
}
#connect-warning-msg p {
margin: 0 0 5px 0;
}
#connect-warning-msg a {
color: #faa;
text-decoration: underline;
}

190
widgets/connect.vue Normal file
View File

@ -0,0 +1,190 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<window
id="connect"
flash-class="home-window-display"
:display="display"
@display="$emit('display', $event)"
>
<div id="connect-frame">
<h1 class="window-title">Establish connection with</h1>
<slot v-if="inputting"></slot>
<connect-switch
v-if="!inputting"
:knowns-length="knowns.length"
:tab="tab"
@switch="switchTab"
></connect-switch>
<connect-new
v-if="tab === 'new' && !inputting"
:connectors="connectors"
@select="selectConnector"
></connect-new>
<connect-known
v-if="tab === 'known' && !inputting"
:presets="presets"
:restricted-to-presets="restrictedToPresets"
:knowns="knowns"
:launcher-builder="knownsLauncherBuilder"
:knowns-export="knownsExport"
:knowns-import="knownsImport"
@select="selectKnown"
@select-preset="selectPreset"
@remove="removeKnown"
@clear-session="clearSessionKnown"
></connect-known>
<div id="connect-warning">
<span id="connect-warning-icon" class="icon icon-warning1"></span>
<div id="connect-warning-msg">
<p>
<strong>An insecured service may steal your secrets.</strong>
Always exam the safety of the service before using it.
</p>
<p>
Sshwifty is a free software, you can deploy it on your own trusted
infrastructure.
<a href="https://github.com/nirui/sshwifty" target="_blank"
>Learn more</a
>
</p>
</div>
</div>
<div v-if="busy" id="connect-busy-overlay"></div>
</div>
</window>
</template>
<script>
import "./connect.css";
import Window from "./window.vue";
import ConnectSwitch from "./connect_switch.vue";
import ConnectKnown from "./connect_known.vue";
import ConnectNew from "./connect_new.vue";
export default {
components: {
window: Window,
"connect-switch": ConnectSwitch,
"connect-known": ConnectKnown,
"connect-new": ConnectNew,
},
props: {
display: {
type: Boolean,
default: false,
},
inputting: {
type: Boolean,
default: false,
},
presets: {
type: Array,
default: () => [],
},
restrictedToPresets: {
type: Boolean,
default: () => false,
},
knowns: {
type: Array,
default: () => [],
},
knownsLauncherBuilder: {
type: Function,
default: () => [],
},
knownsExport: {
type: Function,
default: () => [],
},
knownsImport: {
type: Function,
default: () => [],
},
connectors: {
type: Array,
default: () => [],
},
busy: {
type: Boolean,
default: false,
},
},
data() {
return {
tab: !this.restrictedToPresets ? "new" : "known",
canSelect: true,
};
},
methods: {
switchTab(to) {
if (this.inputting) {
return;
}
this.tab = to;
},
selectConnector(connector) {
if (this.inputting) {
return;
}
this.$emit("connector-select", connector);
},
selectKnown(known) {
if (this.inputting) {
return;
}
this.$emit("known-select", known);
},
removeKnown(uid) {
if (this.inputting) {
return;
}
this.$emit("known-remove", uid);
},
selectPreset(preset) {
if (this.inputting) {
return;
}
this.$emit("preset-select", preset);
},
clearSessionKnown(uid) {
if (this.inputting) {
return;
}
this.$emit("known-clear-session", uid);
},
},
};
</script>

245
widgets/connect_known.css Normal file
View File

@ -0,0 +1,245 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#connect-known-list {
min-height: 200px;
font-size: 0.75em;
background: #3a3a3a;
display: flex;
flex-direction: column;
position: relative;
}
#connect-known-list h3 {
font-size: 1.1em;
color: #999;
margin: 5px 0 15px 0;
text-transform: uppercase;
font-weight: bold;
}
#connect-known-list.reloaded {
}
#connect-known-list.reloaded::after {
opacity: 0;
z-index: 2;
content: " ";
display: block;
position: absolute;
width: 100%;
height: 0;
top: -2px;
left: 0;
right: 0;
background: #fff;
animation-name: home-window-display-flash;
animation-duration: 0.5s;
animation-iteration-count: 1;
box-shadow: 0 0 10px #fff;
}
#connect-known-list-list,
#connect-known-list-empty {
flex: auto;
}
#connect-known-list-empty {
text-align: center;
color: #999;
font-size: 1.2em;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
#connect-known-list-import {
margin: 15px;
color: #aaa;
font-size: 1.1em;
text-align: center;
}
#connect-known-list-import a {
color: #e9a;
text-decoration: none;
}
#connect-known-list-list {
padding: 15px 20px 20px 20px;
}
#connect-known-list-list li {
width: 50%;
position: relative;
}
@media (max-width: 768px) {
#connect-known-list-list li {
width: 100%;
}
}
#connect-known-list-list li > .lst-wrap {
cursor: pointer;
}
#connect-known-list-list li > .lst-wrap:hover {
background: #444;
}
#connect-known-list-list li > .labels {
position: absolute;
top: 0;
left: 0;
text-transform: uppercase;
font-size: 0.85em;
letter-spacing: 1px;
}
#connect-known-list-list li > .labels > .type {
display: inline-block;
padding: 3px;
background: #a56;
color: #fff;
}
#connect-known-list-list li > .labels > .opt {
display: none;
padding: 3px;
background: #a56;
color: #fff;
text-decoration: none;
z-index: 2;
}
@media (max-width: 768px) {
#connect-known-list-list li > .labels > .opt {
display: inline-block;
}
}
#connect-known-list-list li > .labels > .opt.link {
background: #287;
color: #fff;
}
#connect-known-list-list li > .labels > .opt.del {
background: #a56;
color: #fff;
}
#connect-known-list-list li > .labels > .opt.clr {
background: #b71;
color: #fff;
}
#connect-known-list-list li:hover > .labels > .opt,
#connect-known-list-list li:focus > .labels > .opt {
display: inline-block;
}
#connect-known-list-list li > .lst-wrap > h4 {
font-size: 1.5em;
margin-top: 5px;
margin-bottom: 5px;
text-overflow: ellipsis;
overflow: hidden;
}
#connect-known-list-list li > .lst-wrap > h4::before {
content: ">_";
color: #555;
font-size: 0.8em;
margin-right: 5px;
font-weight: normal;
padding: 0 2px;
border-radius: 2px;
}
#connect-known-list-list li > .lst-wrap > h4.highlight::before {
color: #eee;
background: #555;
}
#connect-known-list-presets {
margin-top: 10px;
padding: 15px 20px 20px 20px;
}
#connect-known-list-presets.last-planel {
background: #3f3f3f;
}
#connect-known-list-presets li {
width: 50%;
position: relative;
}
#connect-known-list-presets li.disabled {
opacity: 0.5;
}
@media (max-width: 768px) {
#connect-known-list-presets li {
width: 100%;
}
}
#connect-known-list-presets li > .lst-wrap {
cursor: pointer;
border-radius: 0 3px 3px 3px;
margin: 12px 10px 10px 0;
padding: 10px;
}
#connect-known-list-presets li > .lst-wrap > .labels {
position: absolute;
top: 0;
left: 0;
text-transform: uppercase;
font-size: 0.85em;
letter-spacing: 1px;
}
#connect-known-list-presets li > .lst-wrap > .labels > .type {
display: inline-block;
padding: 3px;
background: #a56;
color: #fff;
border-radius: 3px 3px 3px 0;
}
#connect-known-list-presets li > .lst-wrap > h4 {
font-size: 1.3em;
text-overflow: ellipsis;
overflow: hidden;
}
#connect-known-list-presets-alert {
font-size: 1.15em;
color: #fff;
background: #c73;
padding: 10px;
margin-top: 10px;
line-height: 1.5;
}

352
widgets/connect_known.vue Normal file
View File

@ -0,0 +1,352 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<div id="connect-known-list" :class="{ reloaded: reloaded }">
<div
v-if="knownList.length <= 0 && presets <= 0"
id="connect-known-list-empty"
>
No known remote available
</div>
<div v-else>
<div v-if="knownList.length > 0" id="connect-known-list-list">
<h3>Connected before</h3>
<ul class="hlst lstcl1">
<li v-for="(known, kk) in knownList" :key="kk">
<div class="labels">
<span
class="type"
:style="'background-color: ' + known.data.color"
>
{{ known.data.type }}
</span>
<a
class="opt link"
href="javascript:;"
@click="launcher(known, $event)"
>
{{ known.copyStatus }}
</a>
<a
v-if="!known.data.session"
class="opt del"
href="javascript:;"
@click="remove(known.data.uid)"
>
Remove
</a>
<a
v-else
class="opt clr"
href="javascript:;"
title="Clear session data"
@click="clearSession(known.data.uid)"
>
Clear
</a>
</div>
<div class="lst-wrap" @click="select(known.data)">
<h4
:title="known.data.title"
:class="{ highlight: known.data.session }"
>
{{ known.data.title }}
</h4>
Last: {{ known.data.last.toLocaleString() }}
</div>
</li>
</ul>
</div>
<div
v-if="presets.length > 0"
id="connect-known-list-presets"
:class="{
'last-planel': knownList.length > 0,
}"
>
<h3>Presets</h3>
<ul class="hlst lstcl2">
<li
v-for="(preset, pk) in presets"
:key="pk"
:class="{ disabled: presetDisabled(preset) }"
>
<div class="lst-wrap" @click="selectPreset(preset)">
<div class="labels">
<span
class="type"
:style="'background-color: ' + preset.command.color()"
>
{{ preset.command.name() }}
</span>
</div>
<h4 :title="preset.preset.title()">
{{ preset.preset.title() }}
</h4>
</div>
</li>
</ul>
<div v-if="restrictedToPresets" id="connect-known-list-presets-alert">
The operator has restricted the outgoing connections. You can only
connect to remotes from the pre-defined presets.
</div>
</div>
</div>
<div id="connect-known-list-import">
Tip: You can
<a href="javascript:;" @click="importHosts">import</a> and
<a href="javascript:;" @click="exportHosts">export</a>
known remotes from and to a file.
</div>
</div>
</template>
<script>
import "./connect_known.css";
export default {
props: {
presets: {
type: Array,
default: () => [],
},
restrictedToPresets: {
type: Boolean,
default: () => false,
},
knowns: {
type: Array,
default: () => [],
},
launcherBuilder: {
type: Function,
default: () => [],
},
knownsExport: {
type: Function,
default: () => [],
},
knownsImport: {
type: Function,
default: () => [],
},
},
data() {
return {
knownList: [],
reloaded: false,
busy: false,
};
},
watch: {
knowns(newVal) {
// Only play reload animation when we're adding data into the records,
// not reducing
const playReloaded = newVal.length > this.knownList.length;
this.reload(newVal);
if (!playReloaded) {
return;
}
const self = this;
self.reloaded = true;
setTimeout(() => {
self.reloaded = false;
}, 500);
},
},
mounted() {
this.reload(this.knowns);
},
methods: {
reload(knownList) {
this.knownList = [];
for (let i in knownList) {
this.knownList.unshift({
data: knownList[i],
copying: false,
copyStatus: "Copy link",
});
}
},
select(known) {
if (this.busy) {
return;
}
this.$emit("select", known);
},
presetDisabled(preset) {
if (!this.restrictedToPresets || preset.preset.host().length > 0) {
return false;
}
return true;
},
selectPreset(preset) {
if (this.busy || this.presetDisabled(preset)) {
return;
}
this.$emit("select-preset", preset);
},
async launcher(known, ev) {
if (known.copying || this.busy) {
return;
}
ev.preventDefault();
this.busy = true;
known.copying = true;
known.copyStatus = "Copying";
let lnk = this.launcherBuilder(known.data);
try {
await navigator.clipboard.writeText(lnk);
(() => {
known.copyStatus = "Copied!";
})();
} catch (e) {
(() => {
known.copyStatus = "Failed";
ev.target.setAttribute("href", lnk);
})();
}
setTimeout(() => {
known.copyStatus = "Copy link";
known.copying = false;
}, 2000);
this.busy = false;
},
remove(uid) {
if (this.busy) {
return;
}
this.$emit("remove", uid);
},
clearSession(uid) {
if (this.busy) {
return;
}
this.$emit("clear-session", uid);
},
exportHosts() {
let el = null;
try {
const dataStr = JSON.stringify(this.knownsExport());
el = document.createElement("a");
el.setAttribute(
"href",
"data:text/plain;charset=utf-8," + btoa(dataStr),
);
el.setAttribute("target", "_blank");
el.setAttribute("download", "sshwifty.known-remotes.txt");
el.setAttribute(
"style",
"overflow: hidden; opacity: 0; width: 1px; height: 1px; top: -1px;" +
"left: -1px; position: absolute;",
);
document.body.appendChild(el);
el.click();
} catch (e) {
alert("Unable to export known remotes: " + e);
}
if (el === null) {
return;
}
document.body.removeChild(el);
},
importHosts() {
const self = this;
let el = null;
try {
el = document.createElement("input");
el.setAttribute("type", "file");
el.setAttribute(
"style",
"overflow: hidden; opacity: 0; width: 1px; height: 1px; top: -1px;" +
"left: -1px; position: absolute;",
);
el.addEventListener("change", (ev) => {
const t = ev.target;
if (t.files.length <= 0) {
return;
}
t.disabled = "disabled";
let r = new FileReader();
r.onload = () => {
try {
self.knownsImport(JSON.parse(atob(r.result)));
} catch (e) {
alert("Unable to import known remotes due to error: " + e);
}
};
r.readAsText(t.files[0], "utf-8");
});
document.body.appendChild(el);
el.click();
} catch (e) {
alert("Unable to load known remotes data due to error: " + e);
}
if (el === null) {
return;
}
document.body.removeChild(el);
},
},
};
</script>

58
widgets/connect_new.css Normal file
View File

@ -0,0 +1,58 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#connect-new {
min-height: 200px;
background: #3a3a3a;
font-size: 0.75em;
padding: 15px;
}
#connect-new li .lst-wrap:hover {
background: #544;
}
#connect-new li .lst-wrap:active {
background: #444;
}
#connect-new li .lst-wrap {
cursor: pointer;
color: #aaa;
padding: 15px;
}
#connect-new li h2 {
color: #e9a;
}
#connect-new li h2::before {
content: ">";
margin: 0 5px 0 0;
color: #555;
font-weight: normal;
transition: ease 0.3s margin;
}
#connect-new li .lst-wrap:hover h2::before {
content: ">";
margin: 0 3px 0 2px;
}

53
widgets/connect_new.vue Normal file
View File

@ -0,0 +1,53 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<div id="connect-new">
<ul class="lst1 lst-nostyle">
<li
v-for="(connector, ck) in connectors"
:key="ck"
@click="select(connector)"
>
<div class="lst-wrap">
<h2 :style="'color: ' + connector.color()">{{ connector.name() }}</h2>
{{ connector.description() }}
</div>
</li>
</ul>
</div>
</template>
<script>
import "./connect_new.css";
export default {
props: {
connectors: {
type: Array,
default: () => [],
},
},
methods: {
select(connector) {
this.$emit("select", connector);
},
},
};
</script>

View File

@ -0,0 +1,56 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#connect-switch {
font-size: 0.88em;
color: #aaa;
clear: both;
border-color: #555;
}
#connect-switch li .label {
padding: 2px 7px;
margin-left: 3px;
font-size: 0.85em;
background: #444;
border-radius: 3px;
}
#connect-switch li.active {
border-color: #555;
background: #3a3a3a;
}
#connect-switch li.active .label {
background: #888;
}
#connect-switch li.disabled {
color: #666;
}
#connect-switch.red {
border-color: #a56;
}
#connect-switch.red li.active {
border-color: #a56;
}

View File

@ -0,0 +1,52 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<ul id="connect-switch" class="tab2" :class="{ red: tab === 'known' }">
<li :class="{ active: tab === 'new' }" @click="switchTab('new')">
New remote
</li>
<li :class="{ active: tab === 'known' }" @click="switchTab('known')">
Known remotes <span class="label">{{ knownsLength }}</span>
</li>
</ul>
</template>
<script>
import "./connect_switch.css";
export default {
props: {
tab: {
type: String,
default: "new",
},
knownsLength: {
type: Number,
default: 0,
},
},
methods: {
switchTab(to) {
this.$emit("switch", to);
},
},
};
</script>

132
widgets/connecting.svg Normal file
View File

@ -0,0 +1,132 @@
<svg xmlns="http://www.w3.org/2000/svg" width="450" height="80">
<path d="M104.9 44.333s22.069-11.953 49.294-11.953c27.23 0 49.298 11.953 49.298 11.953" fill="none" stroke="#d18b8d" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="100 150" stroke-dashoffset="100">
<animate id="j" begin="0s;a.end+1s" attributeName="stroke-dashoffset" from="100" to="-1150" dur="2s" fill="freeze" calcMode="linear"/>
</path>
<path d="M203.49 32.393s-22.069 11.953-49.294 11.953c-27.226 0-49.294-11.953-49.294-11.953" fill="none" stroke="gray" stroke-width="3.166" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="100 150" stroke-dashoffset="100">
<animate id="a" begin="b.end" attributeName="stroke-dashoffset" from="100" to="-1150" dur="2s" fill="freeze" calcMode="linear"/>
</path>
<path d="M267.6 37.257h64.101a7.51 7.51 0 1 0 0-15.02 7.51 7.51 0 0 0 0 15.02h31.753" fill="none" stroke="#d18b8d" stroke-width="3.166" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="100 150" stroke-dashoffset="100">
<animate id="d" begin="c.end" attributeName="stroke-dashoffset" from="100" to="-1150" dur="2s" fill="freeze" calcMode="linear"/>
</path>
<path d="M366.36 37.288h-95.739" fill="none" stroke="gray" stroke-width="3.166" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="100 150" stroke-dashoffset="100">
<animate id="k" begin="d.end+0.5s" attributeName="stroke-dashoffset" from="100" to="-1150" dur="2s" fill="freeze" calcMode="linear"/>
</path>
<g>
<path d="M54.652 10.707c-5.462 0-9.914 4.452-9.914 9.914v21.844c0 5.462 4.452 9.914 9.914 9.914h32.762c5.462 0 9.914-4.452 9.914-9.914V20.62c0-5.462-4.452-9.914-9.914-9.914zm0 3.492h32.762c3.589 0 6.424 2.833 6.424 6.422v21.844c0 3.589-2.835 6.424-6.424 6.424H54.652c-3.588 0-6.423-2.835-6.423-6.424V20.62c0-3.589 2.835-6.422 6.423-6.422z" style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;white-space:normal;shape-padding:0;isolation:auto;mix-blend-mode:normal;solid-color:#000;solid-opacity:1" color="#000" font-weight="400" font-family="sans-serif" overflow="visible" fill="gray"/>
<g>
<path d="M62.564 20.131H94.53a6.402 6.402 0 0 1 6.416 6.416v21.601a6.402 6.402 0 0 1-6.416 6.416H62.564a6.402 6.402 0 0 1-6.416-6.416V26.547a6.402 6.402 0 0 1 6.416-6.416z" fill="#262626"/>
<g>
<path d="M59.058 16.727h30.067a7.35 7.35 0 0 1 7.365 7.365v19.702a7.35 7.35 0 0 1-7.365 7.366H59.058a7.35 7.35 0 0 1-7.366-7.366V24.092a7.35 7.35 0 0 1 7.366-7.365z" fill="#d18b8d"/>
<g>
<g>
<path d="M91.056 45.224a3.17 3.17 0 0 1-3.17 3.17 3.17 3.17 0 0 1-3.17-3.17 3.17 3.17 0 0 1 3.17-3.17 3.17 3.17 0 0 1 3.17 3.17z" fill="#fff">
<animate id="e" begin="3s;e.end+10s" attributeName="d" from="m 91.056202,45.223999 a 3.1702001,3.1702001 0 0 1 -3.1702,3.1702 3.1702001,3.1702001 0 0 1 -3.170201,-3.1702 3.1702001,3.1702001 0 0 1 3.170201,-3.1702 3.1702001,3.1702001 0 0 1 3.1702,3.1702 z" to="m 88.773133,45.223999 a 0.8871316,0.8871316 0 0 1 -0.887131,0.887132 0.8871316,0.8871316 0 0 1 -0.887132,-0.887132 0.8871316,0.8871316 0 0 1 0.887132,-0.887132 0.8871316,0.8871316 0 0 1 0.887131,0.887132 z" dur="0.1s" calcMode="linear"/>
</path>
<path d="M67.432 45.224a3.17 3.17 0 0 1-3.17 3.17 3.17 3.17 0 0 1-3.17-3.17 3.17 3.17 0 0 1 3.17-3.17 3.17 3.17 0 0 1 3.17 3.17z" fill="#fff">
<animate begin="e.begin" end="r1fe1.end" attributeName="d" from="m 67.432201,45.223999 a 3.1702001,3.1702001 0 0 1 -3.1702,3.1702 3.1702001,3.1702001 0 0 1 -3.1702,-3.1702 3.1702001,3.1702001 0 0 1 3.1702,-3.1702 3.1702001,3.1702001 0 0 1 3.1702,3.1702 z" to="m 65.149133,45.223999 a 0.8871316,0.8871316 0 0 1 -0.887132,0.887132 0.8871316,0.8871316 0 0 1 -0.887132,-0.887132 0.8871316,0.8871316 0 0 1 0.887132,-0.887132 0.8871316,0.8871316 0 0 1 0.887132,0.887132 z" dur="0.1s" calcMode="linear"/>
</path>
<animateMotion id="f" begin="1s;f.end+2s" from="0.3,0.5" to="0,0.3" dur="5s" calcMode="discrete"/>
</g>
<path d="M72.898 44.274h5.485c.741 0 1.338.597 1.338 1.338v.004c0 .74-.597 1.337-1.338 1.337h-5.485a1.335 1.335 0 0 1-1.338-1.337v-.004c0-.741.597-1.338 1.338-1.338z" fill="#fff"/>
<animateMotion begin="f.begin" end="r1ff1b.end" from="1.2,0.5" to="0,1.2" dur="5s" calcMode="discrete"/>
</g>
<path d="M65.781 21.2H85.82a3.902 3.902 0 0 1 3.91 3.91V36.11a3.902 3.902 0 0 1-3.91 3.91H65.78a3.902 3.902 0 0 1-3.91-3.91V25.11a3.902 3.902 0 0 1 3.91-3.91z" fill="#ed9499"/>
<path d="M68.03 24.089h18.174a3.298 3.298 0 0 1 3.305 3.305v9.304a3.298 3.298 0 0 1-3.305 3.305H68.03a3.298 3.298 0 0 1-3.305-3.305v-9.304a3.298 3.298 0 0 1 3.305-3.305z" fill="#cc7f80"/>
<g fill="#f5d0d0">
<path d="M68.437 27.374h17.787a.73.73 0 1 1 0 1.46H68.437a.73.73 0 1 1 0-1.46zM68.437 31.213h17.787a.73.73 0 1 1 0 1.46H68.437a.73.73 0 1 1 0-1.46zM68.437 35.052h17.787a.73.73 0 1 1 0 1.46H68.437a.73.73 0 1 1 0-1.46z"/>
</g>
<animateMotion begin="f.begin" from="0,0" to="1,0" dur="0.2s" fill="freeze" calcMode="linear"/>
<animateMotion begin="f.end" from="1,0" to="0,0" dur="0.2s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion id="h" begin="g.begin;r1ba2.end" from="0,0" to="0,2" dur="1s" fill="freeze" calcMode="linear"/>
<animateMotion begin="h.end" from="0,2" to="0,0" dur="1s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion id="g" begin="0s;i.end" from="0,0" to="0,3" dur="1s" fill="freeze" calcMode="linear"/>
<animateMotion id="i" begin="g.end" from="0,3" to="0,0" dur="1s" fill="freeze" calcMode="linear"/>
</g>
<path d="M55.93 74.551h31.663c2.193 0 3.958 1.06 3.958 2.375S89.786 79.3 87.593 79.3H55.931c-2.193 0-3.958-1.059-3.958-2.374 0-1.316 1.765-2.375 3.958-2.375z" fill="#262626">
<animate begin="g.begin" end="r1bb1.end" attributeName="d" from="m 60.268629,74.551003 h 22.98674 c 1.591832,0 2.873342,1.059116 2.873342,2.3747 0,1.315583 -1.28151,2.3747 -2.873342,2.3747 h -22.98674 c -1.591831,0 -2.873341,-1.059117 -2.873341,-2.3747 0,-1.315584 1.28151,-2.3747 2.873341,-2.3747 z" to="m 55.930799,74.551003 h 31.6624 c 2.192621,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.765179,2.3747 -3.9578,2.3747 h -31.6624 c -2.192621,0 -3.957799,-1.059117 -3.957799,-2.3747 0,-1.315584 1.765178,-2.3747 3.957799,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
<animate begin="i.begin" end="r1bb2.end" attributeName="d" from="m 55.930799,74.551003 h 31.6624 c 2.192621,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.765179,2.3747 -3.9578,2.3747 h -31.6624 c -2.192621,0 -3.957799,-1.059117 -3.957799,-2.3747 0,-1.315584 1.765178,-2.3747 3.957799,-2.3747 z" to="m 60.268629,74.551003 h 22.98674 c 1.591832,0 2.873342,1.059116 2.873342,2.3747 0,1.315583 -1.28151,2.3747 -2.873342,2.3747 h -22.98674 c -1.591831,0 -2.873341,-1.059117 -2.873341,-2.3747 0,-1.315584 1.28151,-2.3747 2.873341,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
</path>
<g>
<path d="M228.714 3.548h18.122c8.323 0 15.024 6.7 15.024 15.024v29.64c0 8.323-6.7 15.024-15.024 15.024h-18.122c-8.323 0-15.024-6.7-15.024-15.024v-29.64c0-8.323 6.7-15.024 15.024-15.024z" fill="#262626"/>
<g>
<path style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;white-space:normal;shape-padding:0;isolation:auto;mix-blend-mode:normal;solid-color:#000;solid-opacity:1" d="M225.08 2.084c-7.337 0-13.293 5.955-13.293 13.293v30.227c0 7.337 5.956 13.294 13.293 13.294h19.756c7.337 0 13.293-5.957 13.293-13.294V15.377c0-7.338-5.956-13.293-13.293-13.293zm0 3.166h19.756a10.07 10.07 0 0 1 10.127 10.127v30.227a10.071 10.071 0 0 1-10.127 10.128H225.08a10.071 10.071 0 0 1-10.127-10.128V15.377A10.07 10.07 0 0 1 225.08 5.25z" color="#000" font-weight="400" font-family="sans-serif" overflow="visible" fill="gray"/>
<g>
<path d="M230.055 43.997h10.444a3.657 3.657 0 0 1 3.665 3.665 3.657 3.657 0 0 1-3.665 3.665h-10.444a3.657 3.657 0 0 1-3.665-3.665 3.657 3.657 0 0 1 3.665-3.665zM225.185 47.638a3.665 3.665 0 0 1-3.665 3.665 3.665 3.665 0 0 1-3.665-3.665 3.665 3.665 0 0 1 3.665-3.665 3.665 3.665 0 0 1 3.665 3.665zM225.961 8.06h10.069c4.499 0 8.121 3.622 8.121 8.121v8.89c0 4.499-3.622 8.12-8.121 8.12H225.96a8.104 8.104 0 0 1-8.121-8.12v-8.89c0-4.499 3.622-8.121 8.121-8.121z" fill="#d18b8d"/>
<g>
<path d="M224.068 12.572a2.263 2.263 0 0 1 2.268 2.268v3.38a2.263 2.263 0 0 1-2.268 2.268 2.263 2.263 0 0 1-2.268-2.268v-3.38a2.263 2.263 0 0 1 2.268-2.268z" fill="#f9f9f9">
<animate begin="c.begin" attributeName="d" from="m 224.0678,12.572 c 1.25636,0 2.2678,1.011438 2.2678,2.2678 v 3.379999 c 0,1.256362 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011438 -2.2678,-2.2678 V 14.8398 c 0,-1.256362 1.01144,-2.2678 2.2678,-2.2678 z" to="m 224.0678,15.498051 c 1.25636,0 2.2678,1.011438 2.2678,2.2678 v 0.453948 c 0,1.256362 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011438 -2.2678,-2.2678 v -0.453948 c 0,-1.256362 1.01144,-2.2678 2.2678,-2.2678 z" dur="0.1s" fill="freeze" calcMode="linear"/>
<animate begin="b.begin" attributeName="d" from="m 224.0678,15.498051 c 1.25636,0 2.2678,1.011438 2.2678,2.2678 v 0.453948 c 0,1.256362 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011438 -2.2678,-2.2678 v -0.453948 c 0,-1.256362 1.01144,-2.2678 2.2678,-2.2678 z" to="m 224.0678,12.572 c 1.25636,0 2.2678,1.011438 2.2678,2.2678 v 3.379999 c 0,1.256362 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011438 -2.2678,-2.2678 V 14.8398 c 0,-1.256362 1.01144,-2.2678 2.2678,-2.2678 z" dur="0.1s" fill="freeze" calcMode="linear"/>
</path>
<path d="M235.108 15.342a2.263 2.263 0 0 1 2.268 2.268v.61a2.263 2.263 0 0 1-2.268 2.267 2.263 2.263 0 0 1-2.268-2.268v-.61a2.263 2.263 0 0 1 2.268-2.267z" fill="#f9f9f9">
<animate begin="c.begin" attributeName="d" from="m 235.1078,15.342 c 1.25636,0 2.2678,1.011439 2.2678,2.2678 v 0.6095 c 0,1.256361 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011439 -2.2678,-2.2678 v -0.6095 c 0,-1.256361 1.01144,-2.2678 2.2678,-2.2678 z" to="m 235.1078,12.819542 c 1.25636,0 2.2678,1.011439 2.2678,2.2678 V 18.2193 c 0,1.256361 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011439 -2.2678,-2.2678 v -3.131958 c 0,-1.256361 1.01144,-2.2678 2.2678,-2.2678 z" dur="0.1s" fill="freeze" calcMode="linear"/>
<animate begin="b.begin" attributeName="d" from="m 235.1078,12.819542 c 1.25636,0 2.2678,1.011439 2.2678,2.2678 V 18.2193 c 0,1.256361 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011439 -2.2678,-2.2678 v -3.131958 c 0,-1.256361 1.01144,-2.2678 2.2678,-2.2678 z" to="m 235.1078,15.342 c 1.25636,0 2.2678,1.011439 2.2678,2.2678 v 0.6095 c 0,1.256361 -1.01144,2.2678 -2.2678,2.2678 -1.25636,0 -2.2678,-1.011439 -2.2678,-2.2678 v -0.6095 c 0,-1.256361 1.01144,-2.2678 2.2678,-2.2678 z" dur="0.1s" fill="freeze" calcMode="linear"/>
</path>
<animateMotion begin="c.begin" from="0,0" to="5,0" dur="0.2s" fill="freeze" calcMode="linear"/>
<animateMotion begin="b.begin" from="5,0" to="0,0" dur="0.2s" fill="freeze" calcMode="linear"/>
</g>
<path d="M219.554 36.873h22.883c.95 0 1.714.763 1.714 1.712 0 .948-.764 1.712-1.714 1.712h-22.883c-.95 0-1.714-.764-1.714-1.712 0-.949.764-1.712 1.714-1.712z" fill="gray"/>
<animateMotion id="c" begin="j.end" from="0,0" to="7,0" dur="0.2s" fill="freeze" calcMode="linear"/>
<animateMotion id="b" begin="k.end" from="7,0" to="0,0" dur="0.2s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion begin="l.begin" from="0,0" to="0,2" dur="1.5s" fill="freeze" calcMode="linear"/>
<animateMotion begin="m.begin" from="0,2" to="0,0" dur="1.5s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion id="l" begin="0;m.end" from="0,0" to="0,2" dur="1.7s" fill="freeze" calcMode="linear"/>
<animateMotion id="m" begin="l.end" from="0,2" to="0,0" dur="1.7s" fill="freeze" calcMode="linear"/>
</g>
<path d="M222.158 74.551h27.704c2.193 0 3.958 1.06 3.958 2.375s-1.765 2.374-3.958 2.374h-27.704c-2.193 0-3.958-1.059-3.958-2.374 0-1.316 1.765-2.375 3.958-2.375z" fill="#262626">
<animate begin="l.begin" end="r3ba1.end" attributeName="d" from="m 223.41342,74.551003 h 25.19316 c 1.99387,0 3.59905,1.059116 3.59905,2.3747 0,1.315583 -1.60518,2.3747 -3.59905,2.3747 h -25.19316 c -1.99387,0 -3.59905,-1.059117 -3.59905,-2.3747 0,-1.315584 1.60518,-2.3747 3.59905,-2.3747 z" to="m 222.1578,74.551003 h 27.7044 c 2.19262,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.76518,2.3747 -3.9578,2.3747 h -27.7044 c -2.19262,0 -3.9578,-1.059117 -3.9578,-2.3747 0,-1.315584 1.76518,-2.3747 3.9578,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
<animate begin="m.begin" end="r3ba2.end" attributeName="d" from="m 222.1578,74.551003 h 27.7044 c 2.19262,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.76518,2.3747 -3.9578,2.3747 h -27.7044 c -2.19262,0 -3.9578,-1.059117 -3.9578,-2.3747 0,-1.315584 1.76518,-2.3747 3.9578,-2.3747 z" to="m 223.41342,74.551003 h 25.19316 c 1.99387,0 3.59905,1.059116 3.59905,2.3747 0,1.315583 -1.60518,2.3747 -3.59905,2.3747 h -25.19316 c -1.99387,0 -3.59905,-1.059117 -3.59905,-2.3747 0,-1.315584 1.60518,-2.3747 3.59905,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
</path>
<g>
<g>
<path d="M381.245 15.105h23.224a5.892 5.892 0 0 1 5.905 5.905v25.472a5.892 5.892 0 0 1-5.905 5.905h-23.224a5.892 5.892 0 0 1-5.905-5.905V21.01a5.892 5.892 0 0 1 5.905-5.905z" fill="#262626"/>
<g>
<path d="M377.783 14.155h23.968a3.854 3.854 0 0 1 3.863 3.863V44.02a3.854 3.854 0 0 1-3.863 3.863h-23.968a3.854 3.854 0 0 1-3.863-3.863V18.018a3.854 3.854 0 0 1 3.863-3.863z" fill="gray" stroke="gray" stroke-width="2.486"/>
<g>
<path d="M378.21 16.332h20.984a3.373 3.373 0 0 1 3.38 3.38v22.765a3.373 3.373 0 0 1-3.38 3.38H378.21a3.373 3.373 0 0 1-3.38-3.38V19.712a3.373 3.373 0 0 1 3.38-3.38z" fill="#262626"/>
<g>
<path d="M380.116 19.142h18.606a2.99 2.99 0 0 1 2.996 2.996v20.185a2.99 2.99 0 0 1-2.996 2.996h-18.606a2.99 2.99 0 0 1-2.996-2.996V22.138a2.99 2.99 0 0 1 2.996-2.996z" fill="#848484"/>
<g>
<g>
<path d="M382.1 23.891h2.426c.61 0 1.1.49 1.1 1.1v1.318c0 .61-.49 1.1-1.1 1.1H382.1c-.61 0-1.1-.49-1.1-1.1v-1.318c0-.61.49-1.1 1.1-1.1z" fill="#fff">
<animate id="o" begin="5s;n.end+3s" attributeName="d" from="m 382.1003,23.891001 h 2.4261 c 0.60957,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49073,1.1003 -1.1003,1.1003 h -2.4261 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" to="m 382.1003,23.891001 4.04047,0 c 0.60957,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49073,1.1003 -1.1003,1.1003 l -4.04047,0 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" dur="0.1s" fill="freeze" calcMode="linear"/>
<animate id="n" begin="o.end+5s" attributeName="d" from="m 382.1003,23.891001 4.04047,0 c 0.60957,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49073,1.1003 -1.1003,1.1003 l -4.04047,0 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" to="m 382.1003,23.891001 h 2.4261 c 0.60957,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49073,1.1003 -1.1003,1.1003 h -2.4261 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" dur="0.1s" fill="freeze" calcMode="linear"/>
</path>
<path d="M389.42 23.891h6.111c.61 0 1.1.49 1.1 1.1v1.318c0 .61-.49 1.1-1.1 1.1h-6.11c-.61 0-1.101-.49-1.101-1.1v-1.318c0-.61.49-1.1 1.1-1.1z" fill="#fff">
<animate begin="o.begin" attributeName="d" from="m 389.42031,23.891001 h 6.1108 c 0.60956,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49074,1.1003 -1.1003,1.1003 h -6.1108 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" to="m 391.43827,23.891001 h 4.09284 c 0.60956,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49074,1.1003 -1.1003,1.1003 h -4.09284 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" dur="0.1s" fill="freeze" calcMode="linear"/>
<animate begin="n.begin" attributeName="d" from="m 391.43827,23.891001 h 4.09284 c 0.60956,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49074,1.1003 -1.1003,1.1003 h -4.09284 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" to="m 389.42031,23.891001 h 6.1108 c 0.60956,0 1.1003,0.490734 1.1003,1.1003 v 1.3179 c 0,0.609566 -0.49074,1.1003 -1.1003,1.1003 h -6.1108 c -0.60957,0 -1.1003,-0.490734 -1.1003,-1.1003 v -1.3179 c 0,-0.609566 0.49073,-1.1003 1.1003,-1.1003 z" dur="0.1s" fill="freeze" calcMode="linear"/>
</path>
<animateMotion id="p" begin="6s;p.end+5s" from="0,0" to="0,1" dur="0.1s" fill="freeze" calcMode="linear"/>
<animateMotion begin="p.end+2s" from="0,1" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
</g>
<g>
<path d="M393.047 37.15a5.877 5.877 0 0 1-5.877 5.877 5.877 5.877 0 0 1-5.877-5.877 5.877 5.877 0 0 1 5.877-5.877 5.877 5.877 0 0 1 5.877 5.877z" fill="#262626"/>
<path d="M390.68 37.11a3.51 3.51 0 0 1-3.51 3.51 3.51 3.51 0 0 1-3.51-3.51 3.51 3.51 0 0 1 3.51-3.51 3.51 3.51 0 0 1 3.51 3.51z" fill="#d18b8d"/>
<path style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;white-space:normal;shape-padding:0;isolation:auto;mix-blend-mode:normal;solid-color:#000;solid-opacity:1" d="M386.46 30.879c-3.146 0-5.718 2.57-5.718 5.717s2.572 5.717 5.719 5.717c3.147 0 5.717-2.57 5.717-5.717 0-3.148-2.57-5.717-5.717-5.717zm0 1.697c2.23 0 4.019 1.79 4.019 4.02 0 2.23-1.788 4.02-4.018 4.02-2.23 0-4.02-1.79-4.02-4.02 0-2.23 1.79-4.02 4.02-4.02z" color="#000" font-weight="400" font-family="sans-serif" overflow="visible" fill="#aaa"/>
<animateMotion begin="o.begin" from="0,0" to="1,0" dur="0.1s" fill="freeze" calcMode="linear"/>
<animateMotion begin="n.begin" from="1,0" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion begin="o.begin" from="0,0" to="1,0" dur="0.1s" fill="freeze" calcMode="linear"/>
<animateMotion begin="n.begin" from="1,0" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion begin="o.begin" from="0,0" to="-1,0" dur="0.1s" fill="freeze" calcMode="linear"/>
<animateMotion begin="n.begin" from="-1,0" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion begin="o.begin" from="0,0" to="1.3,0" dur="0.1s" fill="freeze" calcMode="linear"/>
<animateMotion begin="n.begin" from="1.3,0" to="0,0" dur="0.1s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion id="r" begin="0s;q.end" from="0,0" to="0,1" dur="1.1s" fill="freeze" calcMode="linear"/>
<animateMotion id="q" begin="r.end" from="0,1" to="0,0" dur="1.1s" fill="freeze" calcMode="linear"/>
</g>
<animateMotion begin="r.begin" from="0,0" to="0,2" dur="1.1s" fill="freeze" calcMode="linear"/>
<animateMotion begin="q.begin" from="0,2" to="0,0" dur="1.1s" fill="freeze" calcMode="linear"/>
</g>
<path d="M380.468 74.551h19.79c2.192 0 3.957 1.06 3.957 2.375s-1.765 2.374-3.958 2.374h-19.79c-2.192 0-3.957-1.059-3.957-2.374 0-1.316 1.765-2.375 3.958-2.375z" fill="#262626">
<animate begin="r.begin" end="r2ba1.end" attributeName="d" from="m 382.77407,74.551003 h 15.17688 c 1.68156,0 3.03531,1.059116 3.03531,2.3747 0,1.315583 -1.35375,2.3747 -3.03531,2.3747 h -15.17688 c -1.68156,0 -3.03531,-1.059117 -3.03531,-2.3747 0,-1.315584 1.35375,-2.3747 3.03531,-2.3747 z" to="m 380.46781,74.551003 h 19.7894 c 2.19262,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.76518,2.3747 -3.9578,2.3747 h -19.7894 c -2.19262,0 -3.9578,-1.059117 -3.9578,-2.3747 0,-1.315584 1.76518,-2.3747 3.9578,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
<animate begin="q.begin" end="r2ba2.end" attributeName="d" from="m 380.46781,74.551003 h 19.7894 c 2.19262,0 3.9578,1.059116 3.9578,2.3747 0,1.315583 -1.76518,2.3747 -3.9578,2.3747 h -19.7894 c -2.19262,0 -3.9578,-1.059117 -3.9578,-2.3747 0,-1.315584 1.76518,-2.3747 3.9578,-2.3747 z" to="m 382.77407,74.551003 h 15.17688 c 1.68156,0 3.03531,1.059116 3.03531,2.3747 0,1.315583 -1.35375,2.3747 -3.03531,2.3747 h -15.17688 c -1.68156,0 -3.03531,-1.059117 -3.03531,-2.3747 0,-1.315584 1.35375,-2.3747 3.03531,-2.3747 z" dur="1s" fill="freeze" calcMode="linear"/>
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

124
widgets/connector.css Normal file
View File

@ -0,0 +1,124 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#connector {
padding: 0 20px 40px 20px;
}
#connector-cancel {
text-decoration: none;
color: #e9a;
}
#connector-cancel.disabled {
color: #444;
}
#connector-cancel::before {
content: "\000AB";
margin-right: 3px;
}
#connector-title {
margin-top: 10px;
text-align: center;
font-size: 0.9em;
color: #aaa;
}
#connector-title > h2 {
color: #e9a;
font-size: 1.3em;
font-weight: bold;
margin: 3px 0;
}
#connector-title.big {
margin: 50px 0;
}
#connector-title.big > h2 {
margin: 10px 0;
}
#connector-fields {
margin-top: 10px;
font-size: 0.9em;
}
#connector-continue {
margin-top: 10px;
font-size: 0.9em;
}
#connector-proccess {
margin-top: 10px;
text-align: center;
font-size: 0.9em;
color: #aaa;
}
#connector-proccess-message {
margin: 30px 0;
}
#connector-proccess-message > h2 {
font-weight: normal;
margin: 10px 0;
color: #e9a;
font-size: 1.2em;
}
#connector-proccess-message > h2 > span {
padding: 2px 10px;
border: 2px solid transparent;
display: inline-block;
}
@keyframes connector-proccess-message-alert {
0% {
border-color: transparent;
}
50% {
outline: 2px solid #e9a;
}
60% {
border-color: #e9a;
outline: none;
}
}
#connector-proccess-message.alert > h2 > span {
outline: 2px solid transparent;
animation-name: connector-proccess-message-alert;
animation-duration: 1.5s;
animation-iteration-count: infinite;
animation-direction: normal;
animation-timing-function: steps(1, end);
}
#connector-proccess-indicater {
width: 100%;
margin: 20px auto;
padding: 0;
}

834
widgets/connector.vue Normal file
View File

@ -0,0 +1,834 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<form
id="connector"
class="form1"
action="javascript:;"
method="POST"
@submit="submitAndGetNext"
>
<a
id="connector-cancel"
href="javascript:;"
:class="{ disabled: working || cancelled }"
@click="cancel()"
>
Cancel
</a>
<div
v-if="!working"
id="connector-title"
:class="{ big: current.fields.length <= 0 }"
>
<h2>{{ current.title || connector.name }}</h2>
<p>{{ current.message || connector.description }}</p>
</div>
<div v-if="working" id="connector-proccess">
<img id="connector-proccess-indicater" src="./connecting.svg" />
<div id="connector-proccess-message" :class="{ alert: current.alert }">
<h2>
<span>{{ current.title || connector.name }}</span>
</h2>
<p>{{ current.message || connector.description }}</p>
</div>
</div>
<fieldset id="connector-fields">
<div
v-for="(field, key) in current.fields"
:key="key"
class="field"
:class="{ error: field.error.length > 0, highlight: field.highlighted }"
>
{{ field.field.name }}
<input
v-if="field.field.type === 'text'"
v-model="field.field.value"
v-focus="field.autofocus"
type="text"
autocomplete="off"
:name="field.field.name"
:placeholder="field.field.example"
:autofocus="field.autofocus"
:tabindex="field.tabIndex"
:disabled="field.field.readonly"
@keydown="triggerSuggestions($event, key, field)"
@focus="focus(key, field, true)"
@blur="focus(key, field, false)"
@input="changed(key, field, false)"
@change="changed(key, field, true)"
/>
<input
v-if="field.field.type === 'password'"
v-model="field.field.value"
v-focus="field.autofocus"
type="password"
autocomplete="off"
:name="field.field.name"
:placeholder="field.field.example"
:autofocus="field.autofocus"
:tabindex="field.tabIndex"
:disabled="field.field.readonly"
@focus="focus(key, field, true)"
@blur="focus(key, field, false)"
@input="changed(key, field, false)"
@change="changed(key, field, true)"
/>
<input
v-if="field.field.type === 'checkbox'"
v-model="field.field.value"
v-focus="field.autofocus"
type="checkbox"
autocomplete="off"
:name="field.field.name"
:autofocus="field.autofocus"
:tabindex="field.tabIndex"
:disabled="field.field.readonly"
@focus="focus(key, field, true)"
@blur="focus(key, field, false)"
@input="changed(key, field, false)"
@change="changed(key, field, true)"
/>
<textarea
v-if="field.field.type === 'textarea'"
v-model="field.field.value"
v-focus="field.autofocus"
autocomplete="off"
:placeholder="field.field.example"
:name="field.field.name"
:autofocus="field.autofocus"
:tabindex="field.tabIndex"
:disabled="field.field.readonly"
@keyup="expandTextarea($event)"
@keydown="
triggerSuggestions($event, key, field) || expandTextarea($event)
"
@focus="focus(key, field, true)"
@blur="focus(key, field, false)"
@input="changed(key, field, false)"
@change="changed(key, field, true)"
></textarea>
<input
v-if="field.field.type === 'textfile'"
v-focus="field.autofocus"
type="file"
autocomplete="off"
:placeholder="field.field.example"
:name="field.field.name + '-file'"
:autofocus="field.autofocus"
:tabindex="field.tabIndex"
:disabled="field.field.readonly"
@focus="focus(key, field, true)"
@blur="focus(key, field, false)"
@change="importFile($event.target, field)"
/>
<input
v-if="field.field.type === 'textfile'"
v-model="field.field.value"
type="text"
autocomplete="off"
:name="field.field.name"
:placeholder="field.field.example"
style="display: none"
@input="changed(key, field, false)"
@change="changed(key, field, true)"
/>
<select
v-if="field.field.type === 'select'"
v-model="field.field.value"
v-focus="field.autofocus"
autocomplete="off"
:name="field.field.name"
:autofocus="field.autofocus"
:value="field.field.value"
:tabindex="field.tabIndex"
:disabled="field.field.readonly"
@focus="focus(key, field, true)"
@blur="focus(key, field, false)"
@input="changed(key, field, false)"
@change="changed(key, field, true)"
>
<option
v-for="(option, oKey) in field.field.example.split(',')"
:key="oKey"
:value="option"
:selected="field.field.value === option"
:aria-selected="field.field.value === option"
>
{{ option }}
</option>
</select>
<div v-if="field.field.type === 'textdata'" class="textinfo">
<div class="info">{{ field.field.value }}</div>
</div>
<div v-if="field.field.type === 'radio'" class="items">
<label
v-for="(option, oKey) in field.field.example.split(',')"
:key="oKey"
class="field horizontal item"
>
<input
v-model="field.field.value"
v-focus="field.autofocus && oKey === 0"
type="radio"
autocomplete="off"
:name="field.field.name"
:autofocus="field.autofocus && oKey === 0"
:value="option"
:checked="field.field.value === option"
:aria-checked="field.field.value === option"
:tabindex="field.nextSubTabIndex(oKey)"
:disabled="field.field.readonly"
@focus="focus(key, field, true)"
@blur="focus(key, field, false)"
@input="changed(key, field, false)"
@change="changed(key, field, true)"
/>
{{ option }}
</label>
</div>
<ul
v-if="field.suggestion.suggestions.length > 0"
class="input-suggestions lst-nostyle"
@mouseenter="field.holdSuggestions(true)"
@mouseleave="field.holdSuggestions(false)"
>
<li
v-for="(suggestion, sKey) in field.suggestion.suggestions"
:key="sKey"
:class="{ current: sKey === field.suggestion.selected }"
@click="clickInputSuggestion(key, field, sKey)"
>
<div class="sugt-title">
{{ suggestion.title }}
</div>
<div class="sugt-value">
{{ suggestion.value }}
</div>
</li>
</ul>
<div v-if="field.error.length > 0" class="error">{{ field.error }}</div>
<div v-else-if="field.message.length > 0" class="message">
{{ field.message }}
</div>
<div
v-else-if="field.field.description.length > 0"
class="message"
v-html="field.field.description"
></div>
</div>
<div class="field">
<button
v-if="current.submittable"
v-focus="submitterTabIndex === 1"
type="submit"
:disabled="current.submitting || disabled"
:tabindex="submitterTabIndex"
:autofocus="submitterTabIndex === 1"
@click="submitAndGetNext"
>
{{ current.actionText }}
</button>
<button
v-if="current.cancellable"
:disabled="current.submitting || disabled"
:tabindex="submitterTabIndex + 1"
class="secondary"
@click="cancelAndGetNext"
>
Cancel
</button>
</div>
</fieldset>
<div
v-if="preloaderIDName.length > 0"
style="
width: 1px;
height: 1px;
margin: 10px;
position: absolute;
top: 0;
bottom: 0;
overflow: hidden;
"
>
<div :id="preloaderIDName">
{{ current.title || connector.name }} wizard
</div>
</div>
</form>
</template>
<script>
import "./connector.css";
import * as command from "../commands/commands.js";
import * as fieldBuilder from "./connector_field_builder.js";
const preloaderIDPrefix = "connector-resource-preload-control-";
const hightlightClearTimeout = 1000;
function buildEmptyCurrent() {
return {
data: null,
alert: false,
clearHightlightTimeout: null,
title: "",
message: "",
fields: [],
actionText: "Continue",
cancellable: false,
submittable: false,
submitting: false,
};
}
export default {
directives: {
focus: {
inserted(el, binding) {
if (!binding.value) {
return;
}
el.focus();
},
},
},
props: {
connector: {
type: Object,
default: () => null,
},
},
data() {
return {
currentConnector: null,
currentConnectorCloseWait: null,
current: buildEmptyCurrent(),
preloaderIDName: "",
fieldValueBackup: [],
submitterTabIndex: 1,
working: false,
disabled: false,
cancelled: false,
};
},
watch: {
async connector(oldV, newV) {
if (this.currentConnector !== null) {
await this.closeWizard();
}
this.cancelled = false;
this.currentConnector = newV;
this.runWizard();
},
},
async mounted() {
await this.closeWizard();
this.runWizard();
this.cancelled = false;
},
async beforeDestroy() {
try {
await this.closeWizard();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
},
methods: {
async sendCancel() {
await this.closeWizard();
this.$emit("cancel", true);
},
cancel() {
if (this.working) {
return;
}
if (this.cancelled) {
return;
}
this.cancelled = true;
this.sendCancel();
},
buildCurrent(next) {
try {
this.current = buildEmptyCurrent();
this.working = this.getConnector().wizard.started();
this.current.type = next.type();
this.current.data = next.data();
let fields = null,
tabIndex = 1;
switch (this.current.type) {
case command.NEXT_PROMPT:
fields = this.current.data.inputs();
for (let i = 0; i < fields.length; i++) {
const f = fieldBuilder.build(tabIndex, i, fields[i]);
if (f.field.readonly) {
this.verify(i, f, true);
}
this.current.fields.push(f);
tabIndex = f.nextTabIndex();
}
this.submitterTabIndex = tabIndex > 0 ? tabIndex : 1;
this.current.actionText = this.current.data.actionText();
this.current.submittable = true;
this.current.alert = true;
this.current.cancellable = true;
// Fallthrough
case command.NEXT_WAIT:
this.current.title = this.current.data.title();
this.current.message = this.current.data.message();
break;
case command.NEXT_DONE:
this.working = false;
this.disabled = true;
if (!this.current.data.success()) {
this.current.title = this.current.data.error();
this.current.message = this.current.data.message();
} else {
this.$emit("done", this.current.data.data());
}
break;
default:
throw new Error("Unknown command type");
}
if (!this.working) {
this.current.cancellable = false;
}
return next;
} catch (e) {
this.current.title = "Encountered an error";
this.current.message = e;
this.working = false;
this.disabled = true;
throw e;
}
},
getConnector() {
if (this.currentConnector === null) {
this.currentConnector = this.connector;
}
return this.currentConnector;
},
async closeWizard() {
if (this.currentConnectorCloseWait === null) {
return;
}
let waiter = this.currentConnectorCloseWait;
this.currentConnectorCloseWait = null;
this.getConnector().wizard.close();
try {
await waiter;
} catch (e) {
// Do nothing
}
},
runWizard() {
if (this.currentConnectorCloseWait !== null) {
throw new Error("Cannot run wizard multiple times");
}
this.preloaderIDName =
preloaderIDPrefix +
this.getConnector().wizard.control().ui().toLowerCase();
this.currentConnectorCloseWait = (async () => {
while (!this.disabled) {
this.clearFieldValueBackup();
let next = this.buildCurrent(await this.getConnector().wizard.next());
switch (next.type()) {
case command.NEXT_PROMPT:
case command.NEXT_WAIT:
continue;
case command.NEXT_DONE:
return;
default:
throw new Error("Unknown command type");
}
}
})();
},
getFieldValues() {
let mod = {};
for (let i = 0; i < this.current.fields.length; i++) {
mod[this.current.fields[i].field.name] =
this.current.fields[i].field.value;
}
return mod;
},
createFieldValueBackup() {
let backup = [];
for (let i = 0; i < this.current.fields.length; i++) {
backup.push(this.current.fields[i].field.value);
}
this.fieldValueBackup = backup;
},
clearFieldValueBackup() {
this.fieldValueBackup = [];
},
clearFieldHighlights() {
for (let i = 0; i < this.current.fields.length; i++) {
this.current.fields[i].highlighted = false;
}
},
delayedClearFieldHighlights(timeout) {
const self = this;
if (self.clearHightlightTimeout === null) {
clearTimeout(self.clearHightlightTimeout);
self.clearHightlightTimeout = null;
}
self.clearHightlightTimeout = setTimeout(() => {
self.clearHightlightTimeout = null;
self.clearFieldHighlights();
}, timeout);
},
restoreFieldValuesFromBackup(except) {
for (let i = 0; i < this.fieldValueBackup.length; i++) {
if (except === i) {
continue;
}
this.current.fields[i].field.value = this.fieldValueBackup[i];
}
},
expandTextarea(event) {
// WARNING: This function may cause rendering stutter due to
// combined problem of CSS "Position" and Vue render.
// Use of "TextArea" element is thus not recommended.
event.target.style.height = "";
event.target.style.height = event.target.scrollHeight + "px";
},
importFile(el, field) {
if (el.files.length <= 0) {
return;
}
el.disabled = "disabled";
let r = new FileReader();
r.onload = () => {
let s = el.nextSibling;
for (;;) {
if (s.tagName !== "INPUT") {
s = s.nextSibling;
continue;
}
field.field.value = r.result;
s.dispatchEvent(new Event("change"));
break;
}
el.disabled = "";
};
r.readAsText(el.files[0], "utf-8");
},
verify(key, field, force) {
try {
field.message = "" + field.field.verify(field.field.value);
field.modified = true;
field.verified = true;
field.error = "";
} catch (e) {
field.error = "";
field.message = "";
field.verified = false;
if (field.modified || force) {
field.error = "" + e;
}
}
field.highlighted = false;
if (
!field.verified &&
(field.modified || force) &&
field.error.length <= 0
) {
field.error = "Invalid";
}
return field.verified;
},
verifyAll() {
let verified = true;
for (let i = 0; i < this.current.fields.length; i++) {
if (this.verify(i, this.current.fields[i], true)) {
continue;
}
verified = false;
}
return verified;
},
focus(key, field, focused) {
field.highlighted = false;
if (!focused) {
// Don't reset a holding field
if (!field.inputted) {
field.resetSuggestions(false);
} else if (field.resetSuggestions(false)) {
this.clickInputSuggestion(
key,
field,
field.selectedSuggestionIndex(),
);
}
return;
}
this.createFieldValueBackup();
field.reloadSuggestions();
},
applySuggestion(key, field, suggestion) {
this.restoreFieldValuesFromBackup(-1);
field.field.value = suggestion.value;
for (let i = 0; i < this.current.fields.length; i++) {
this.current.fields[i].highlighted = false;
if (
i === key ||
this.current.fields[i].inputted ||
this.current.fields[i].field.readonly
) {
continue;
}
if (
typeof suggestion.fields[this.current.fields[i].field.name] ===
"undefined"
) {
continue;
}
this.current.fields[i].field.value =
suggestion.fields[this.current.fields[i].field.name];
if (!this.verify(i, this.current.fields[i], true)) {
continue;
}
this.current.fields[i].highlighted = true;
}
},
applySuggestionAndVerify(key, field, force, suggestion) {
field.inputted = true;
this.applySuggestion(key, field, suggestion);
return this.verify(key, field, force);
},
changed(key, field, force) {
this.createFieldValueBackup();
field.highlighted = false;
field.inputted = true;
field.enableInputSuggestionsOnAllInput();
field.reloadSuggestions();
this.verify(key, field, force);
},
triggerSuggestions(event, key, field) {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
field.moveSuggestionsCursor(true);
this.applySuggestionAndVerify(
key,
field,
true,
field.curentSuggestion(),
);
break;
case "ArrowDown":
event.preventDefault();
field.moveSuggestionsCursor(false);
this.applySuggestionAndVerify(
key,
field,
true,
field.curentSuggestion(),
);
break;
case "Escape":
if (!field.suggestionsPending()) {
return;
}
event.preventDefault();
this.restoreFieldValuesFromBackup(key);
this.clearFieldValueBackup();
this.clearFieldHighlights();
this.verify(key, field, true);
field.disableSuggestionsForInput(field.field.value);
field.resetSuggestions(true);
break;
case "Enter":
if (!field.suggestionsPending()) {
return;
}
event.preventDefault();
this.clickInputSuggestion(
key,
field,
field.selectedSuggestionIndex(),
);
break;
}
},
clickInputSuggestion(key, field, index) {
const self = this;
field.selectSuggestion(index);
if (
self.applySuggestionAndVerify(
key,
field,
true,
field.curentSuggestion(),
)
) {
field.disableSuggestionsForInput(field.field.value);
} else {
field.enableInputSuggestionsOnAllInput();
}
field.resetSuggestions(true);
self.clearFieldValueBackup();
self.delayedClearFieldHighlights(hightlightClearTimeout);
},
async submitAndGetNext() {
if (this.current.submitting || this.disabled) {
return;
}
if (this.current.data === null || !this.current.submittable) {
return;
}
if (!this.verifyAll()) {
return;
}
this.current.submitting = true;
try {
await this.current.data.submit(this.getFieldValues());
} catch (e) {
this.current.submitting = false;
alert("Submission has failed: " + e);
process.env.NODE_ENV === "development" && console.trace(e);
return;
}
},
async cancelAndGetNext() {
if (this.current.submitting || this.disabled) {
return;
}
if (this.current.data === null || !this.current.cancellable) {
return;
}
this.current.submitting = true;
await this.current.data.cancel();
},
},
};
</script>

View File

@ -0,0 +1,222 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
function getTabIndex(tabIndex, field) {
if (field.readonly) {
return 0;
}
switch (field.type) {
case "text":
case "password":
case "checkbox":
case "textarea":
case "textfile":
case "select":
case "radio":
return tabIndex;
default:
return 0;
}
}
export function build(tabIndex, i, field) {
return {
verified: false,
modified: false,
inputted: false,
highlighted: false,
error: "",
message: "",
field: field,
autofocus: tabIndex === 1 && !field.readonly,
tabIndex: getTabIndex(tabIndex, field),
blockedSuggestionValue: "",
blockingSuggestion: false,
nextTabIndex() {
let nextTabIndex = 0;
if (this.field.readonly) {
nextTabIndex = this.tabIndex;
} else {
switch (this.field.type) {
case "radio":
nextTabIndex = this.tabIndex + this.field.example.split(",").length;
break;
default:
nextTabIndex = this.tabIndex + 1;
}
}
if (tabIndex >= nextTabIndex) {
return tabIndex;
}
return nextTabIndex;
},
nextSubTabIndex(subIndex) {
if (this.field.readonly) {
return 0;
}
return this.tabIndex + subIndex;
},
suggestion: {
selected: -1,
suggestions: [],
orignalValue: "",
orignalValueStored: false,
holding: false,
needsReset: false,
reset() {
this.selected = -1;
this.suggestions = [];
this.holding = false;
this.needsReset = false;
this.clearStored();
return true;
},
softReset() {
if (this.holding) {
this.needsReset = true;
return false;
}
return this.reset();
},
hold(toHold) {
this.holding = toHold;
if (this.holding || !this.needsReset) {
return;
}
this.reset();
},
storeOrignal(val) {
if (this.orignalValueStored) {
return;
}
this.orignalValue = val;
this.orignalValueStored = true;
},
loadStored(defaultValue) {
return this.orignalValueStored ? this.orignalValue : defaultValue;
},
clearStored() {
this.orignalValue = "";
this.orignalValueStored = false;
},
select(index, fieldValue) {
if (this.selected < 0) {
this.storeOrignal(fieldValue);
}
if (index < -1 || index >= this.suggestions.length) {
return;
}
this.selected = index;
},
cursorUp(fieldValue) {
this.select(this.selected - 1, fieldValue);
},
cursorDown(fieldValue) {
this.select(this.selected + 1, fieldValue);
},
cursorMove(toUp, fieldValue) {
toUp ? this.cursorUp(fieldValue) : this.cursorDown(fieldValue);
},
reload(fieldValue, suggestions) {
this.selected = -1;
this.suggestions = [];
this.clearStored();
if (suggestions.length === 1 && suggestions[0].value === fieldValue) {
return;
}
for (let v in suggestions) {
this.suggestions.push({
title: suggestions[v].title,
value: suggestions[v].value,
fields: suggestions[v].meta,
});
}
},
current(defaultValue) {
if (this.selected < 0) {
return {
title: "Input",
value: this.loadStored(defaultValue),
fields: {},
};
}
return this.suggestions[this.selected];
},
},
disableSuggestionsForInput(val) {
this.blockedSuggestionValue = val;
this.blockingSuggestion = true;
},
enableInputSuggestionsOnAllInput() {
this.blockedSuggestionValue = "";
this.blockingSuggestion = false;
},
suggestionsPending() {
return this.suggestion.suggestions.length > 0;
},
reloadSuggestions() {
if (
this.blockingSuggestion &&
this.field.value === this.blockedSuggestionValue
) {
return;
}
this.suggestion.reload(
this.field.value,
this.field.suggestions(this.field.value),
);
},
resetSuggestions(force) {
return force ? this.suggestion.reset() : this.suggestion.softReset();
},
holdSuggestions(toHold) {
this.suggestion.hold(toHold);
},
moveSuggestionsCursor(toUp) {
this.suggestion.cursorMove(toUp, this.field.value);
},
selectSuggestion(index) {
this.suggestion.select(index, this.field.value);
},
curentSuggestion() {
return this.suggestion.current(this.field.value);
},
selectedSuggestionIndex() {
return this.suggestion.selected;
},
};
}

246
widgets/screen_console.css Normal file
View File

@ -0,0 +1,246 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
@import "~hack-font/build/web/hack.css";
@import "~@azurity/pure-nerd-font/pure-nerd-font.css";
#connector-resource-preload-control-console {
font-family: PureNerdFont, Hack;
}
#connector-resource-preload-control-console::after {
content: " ";
font-family: PureNerdFont, Hack;
font-weight: bold;
}
#connector-resource-preload-control-console::before {
content: " ";
font-family: PureNerdFont, Hack;
font-style: italic;
}
#home-content > .screen > .screen-screen > .screen-console {
position: relative;
min-height: 1px;
}
#home-content > .screen > .screen-screen > .screen-console > .console-toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
max-height: 100%;
overflow: auto;
background: #222;
color: #fff;
box-shadow: 0 0 5px #0006;
z-index: 1;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group.console-toolbar-group-left {
border-right: 1px solid #0001;
margin-right: -1px;
float: left;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group.console-toolbar-group-main {
border-left: 1px solid #0001;
overflow: auto;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item {
padding: 15px;
float: left;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item
.tb-title {
font-size: 0.7em;
text-transform: uppercase;
margin: 0 0 5px 10px;
color: #fff9;
text-shadow: 1px 1px 1px #0005;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item
.tb-item {
display: block;
font-size: 0.7em;
padding: 10px;
text-decoration: none;
color: inherit;
border-radius: 3px;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item
.tb-item:active {
background: #fff3;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item
.tb-item
> .tb-key-icon {
margin: 0 5px;
background: #fff2;
color: #fff;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item
.tb-item
> .tb-key-icon.tb-key-resize-icon {
display: block;
padding: 5px;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item
.tb-item
> .tb-key-icon:first-child {
margin-left: 0;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item
.tb-item
> .tb-key-icon:last-child {
margin-right: 0;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-toolbar
> .console-toolbar-group
> .console-toolbar-item
.tb-item:active
.tb-key-icon {
opacity: 0.5;
}
#home-content > .screen > .screen-screen > .screen-console > .console-console {
color: #fff;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
position: relative;
z-index: 0;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-console
> .console-loading {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
display: flex;
flex-direction: row;
align-items: center;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-console
> .console-loading
> .console-loading-frame {
text-align: center;
font-size: 1em;
font-weight: lighter;
padding: 20px;
margin: 10px auto;
flex: auto;
}
#home-content
> .screen
> .screen-screen
> .screen-console
> .console-console
> .console-loading
> .console-loading-frame
> .console-loading-icon {
background: url(./busy.svg) 50% no-repeat;
width: 100%;
height: 100px;
}

591
widgets/screen_console.vue Normal file
View File

@ -0,0 +1,591 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<div class="screen-console">
<div
class="console-console"
:style="'font-family: ' + typefaces + ', inherit'"
>
<h2 style="display: none">Console</h2>
<div class="console-loading">
<div class="console-loading-frame">
<div class="console-loading-icon"></div>
<div class="console-loading-message">Initializing console ...</div>
</div>
</div>
</div>
<!--
Tell you this: the background transparent below is probably the most
important transparent setting in the entire application. Make sure user
can see through it so they can operate the console while keep the toolbar
open.
-->
<div
v-if="toolbar"
class="console-toolbar"
:style="'background-color: ' + control.activeColor() + 'ee'"
>
<h2 style="display: none">Tool bar</h2>
<div class="console-toolbar-group console-toolbar-group-left">
<div class="console-toolbar-item">
<h3 class="tb-title">Text size</h3>
<ul class="lst-nostyle">
<li>
<a class="tb-item" href="javascript:;" @click="fontSizeUp">
<span
class="tb-key-icon tb-key-resize-icon icon icon-keyboardkey1 icon-iconed-bottom1"
>
<i>+</i>
Increase
</span>
</a>
</li>
<li>
<a class="tb-item" href="javascript:;" @click="fontSizeDown">
<span
class="tb-key-icon tb-key-resize-icon icon icon-keyboardkey1 icon-iconed-bottom1"
>
<i>-</i>
Decrease
</span>
</a>
</li>
</ul>
</div>
</div>
<div class="console-toolbar-group console-toolbar-group-main">
<div
v-for="(keyType, keyTypeIdx) in screenKeys"
:key="keyTypeIdx"
class="console-toolbar-item"
>
<h3 class="tb-title">{{ keyType.title }}</h3>
<ul class="hlst lst-nostyle">
<li v-for="(key, keyIdx) in keyType.keys" :key="keyIdx">
<a
class="tb-item"
href="javascript:;"
@click="sendSpecialKey(key[1])"
v-html="$options.filters.specialKeyHTML(key[0])"
></a>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import FontFaceObserver from "fontfaceobserver";
import { Terminal } from "@xterm/xterm";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { WebglAddon } from "@xterm/addon-webgl";
import { FitAddon } from "@xterm/addon-fit";
import { isNumber } from "../commands/common.js";
import { consoleScreenKeys } from "./screen_console_keys.js";
import "./screen_console.css";
import "xterm/css/xterm.css";
const termTypeFaces = "PureNerdFont, Hack";
const termFallbackTypeFace = '"Cascadia Code" , monospace';
const termTypeFaceLoadTimeout = 3000;
const termTypeFaceLoadError =
"Remote font " +
termTypeFaces +
" is unavailable, using " +
termFallbackTypeFace +
" instead until the remote font is loaded";
const termDefaultFontSize = 16;
const termMinFontSize = 8;
const termMaxFontSize = 36;
function webglSupported() {
try {
if (typeof window !== "object") {
return false;
}
if (typeof window.WebGLRenderingContext !== "function") {
return false;
}
if (typeof window.WebGL2RenderingContext !== "function") {
return false;
}
return document.createElement('canvas').getContext('webgl') &&
document.createElement('canvas').getContext('webgl2');
} catch(e) {
}
return false;
}
class Term {
constructor(control) {
const resizeDelayInterval = 500;
this.control = control;
this.closed = false;
this.fontSize = termDefaultFontSize;
this.term = new Terminal({
allowProposedApi: true,
allowTransparency: false,
cursorBlink: true,
cursorStyle: "block",
fontFamily: termTypeFaces + ", " + termFallbackTypeFace,
fontSize: this.fontSize,
logLevel: process.env.NODE_ENV === "development" ? "info" : "off",
theme: {
background: this.control.activeColor(),
},
});
this.fit = new FitAddon();
this.term.onData((data) => {
if (this.closed) {
return;
}
this.control.send(data);
});
this.term.onBinary((data) => {
if (this.closed) {
return;
}
this.control.sendBinary(data);
});
this.term.onKey((ev) => {
if (this.closed) {
return;
}
if (!this.control.echo()) {
return;
}
const printable =
!ev.domEvent.altKey &&
!ev.domEvent.altGraphKey &&
!ev.domEvent.ctrlKey &&
!ev.domEvent.metaKey;
switch (ev.domEvent.key) {
case "Enter":
ev.domEvent.preventDefault();
this.writeStr("\r\n");
break;
case "Backspace":
ev.domEvent.preventDefault();
this.writeStr("\b \b");
break;
default:
if (printable) {
ev.domEvent.preventDefault();
this.writeStr(ev.key);
}
}
});
let resizeDelay = null,
oldRows = 0,
oldCols = 0;
this.term.onResize((dim) => {
if (this.closed) {
return;
}
if (dim.cols === oldCols && dim.rows === oldRows) {
return;
}
oldRows = dim.rows;
oldCols = dim.cols;
if (resizeDelay !== null) {
clearTimeout(resizeDelay);
resizeDelay = null;
}
resizeDelay = setTimeout(() => {
resizeDelay = null;
if (!isNumber(dim.cols) || !isNumber(dim.rows)) {
return;
}
if (!dim.cols || !dim.rows) {
return;
}
this.control.resize({
rows: dim.rows,
cols: dim.cols,
});
}, resizeDelayInterval);
});
}
init(root) {
if (this.closed) {
return;
}
this.term.open(root);
this.term.loadAddon(this.fit);
this.term.loadAddon(new WebLinksAddon());
this.term.loadAddon(new Unicode11Addon());
try {
if (webglSupported()) {
this.term.loadAddon(new WebglAddon());
}
} catch(e) {}
this.term.unicode.activeVersion = '11';
this.refit();
}
dispatch(event) {
if (this.closed) {
return;
}
try {
this.term.textarea.dispatchEvent(event);
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
writeStr(d) {
if (this.closed) {
return;
}
try {
this.term.write(d);
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
setFont(value) {
if (this.closed) {
return;
}
this.term.options.fontFamily = value;
this.refit();
}
fontSizeUp() {
if (this.closed) {
return;
}
if (this.fontSize >= termMaxFontSize) {
return;
}
this.fontSize += 2;
this.term.options.fontSize = this.fontSize;
this.refit();
}
fontSizeDown() {
if (this.closed) {
return;
}
if (this.fontSize <= termMinFontSize) {
return;
}
this.fontSize -= 2;
this.term.options.fontSize = this.fontSize;
this.refit();
}
focus() {
if (this.closed) {
return;
}
try {
this.term.focus();
this.refit();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
blur() {
if (this.closed) {
return;
}
try {
this.term.blur();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
refit() {
if (this.closed) {
return;
}
try {
this.fit.fit();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
destroyed() {
return this.closed;
}
destroy() {
if (this.closed) {
return;
}
this.closed = true;
try {
this.term.dispose();
} catch (e) {
process.env.NODE_ENV === "development" && console.trace(e);
}
}
}
// So it turns out, display: none + xterm.js == trouble, so I changed this
// to a visibility + position: absolute appoarch. Problem resolved, and I
// like to keep it that way.
export default {
filters: {
specialKeyHTML(key) {
const head = '<span class="tb-key-icon icon icon-keyboardkey1">',
tail = "</span>";
return head + key.split("+").join(tail + "+" + head) + tail;
},
},
props: {
active: {
type: Boolean,
default: false,
},
control: {
type: Object,
default: () => null,
},
change: {
type: Object,
default: () => null,
},
toolbar: {
type: Boolean,
default: false,
},
viewPort: {
type: Object,
default: () => null,
},
},
data() {
return {
screenKeys: consoleScreenKeys,
term: new Term(this.control),
typefaces: termTypeFaces,
runner: null,
eventHandlers: {
keydown: null,
keyup: null,
},
};
},
watch: {
active(newVal, oldVal) {
this.triggerActive(newVal);
},
change: {
handler() {
if (!this.active) {
return;
}
this.fit();
},
deep: true,
},
viewPort: {
handler() {
if (!this.active) {
return;
}
this.fit();
},
deep: true,
},
},
async mounted() {
await this.init();
},
beforeDestroy() {
this.deinit();
},
methods: {
loadRemoteFont(typefaces, timeout) {
const tfs = typefaces.split(",");
let observers = [];
for (let v in tfs) {
observers.push(new FontFaceObserver(tfs[v].trim()).load(null, timeout));
observers.push(
new FontFaceObserver(tfs[v].trim(), {
weight: "bold",
}).load(null, timeout),
);
}
return Promise.all(observers);
},
async retryLoadRemoteFont(typefaces, timeout, onSuccess) {
const self = this;
for (;;) {
try {
onSuccess(await self.loadRemoteFont(typefaces, timeout));
return;
} catch (e) {
// Retry
}
}
},
async openTerm(root, callbacks) {
const self = this;
try {
await self.loadRemoteFont(termTypeFaces, termTypeFaceLoadTimeout);
if (self.term.destroyed()) {
return;
}
root.innerHTML = "";
self.term.init(root);
return;
} catch (e) {
// Ignore
}
if (self.term.destroyed()) {
return;
}
root.innerHTML = "";
callbacks.warn(termTypeFaceLoadError, false);
self.term.setFont(termFallbackTypeFace);
self.term.init(root);
self.retryLoadRemoteFont(termTypeFaces, termTypeFaceLoadTimeout, () => {
if (self.term.destroyed()) {
return;
}
self.term.setFont(termTypeFaces);
callbacks.warn(termTypeFaceLoadError, true);
});
},
triggerActive(active) {
active ? this.activate() : this.deactivate();
},
async init() {
let self = this;
await self.openTerm(
self.$el.getElementsByClassName("console-console")[0],
{
warn(msg, toDismiss) {
self.$emit("warning", {
text: msg,
toDismiss: toDismiss,
});
},
info(msg, toDismiss) {
self.$emit("info", {
text: msg,
toDismiss: toDismiss,
});
},
},
);
if (self.term.destroyed()) {
return;
}
self.triggerActive(this.active);
self.runRunner();
},
async deinit() {
await this.closeRunner();
await this.deactivate();
this.term.destroy();
},
fit() {
this.term.refit();
},
activate() {
this.term.focus();
this.fit();
},
async deactivate() {
this.term.blur();
},
runRunner() {
if (this.runner !== null) {
return;
}
let self = this;
this.runner = (async () => {
try {
for (;;) {
if (self.term.destroyed()) {
break;
}
self.term.writeStr(await this.control.receive());
self.$emit("updated");
}
} catch (e) {
self.$emit("stopped", e);
}
})();
},
async closeRunner() {
if (this.runner === null) {
return;
}
let runner = this.runner;
this.runner = null;
await runner;
},
sendSpecialKey(key) {
if (!this.term) {
return;
}
this.term.dispatch(new KeyboardEvent("keydown", key));
this.term.dispatch(new KeyboardEvent("keyup", key));
},
fontSizeUp() {
this.term.fontSizeUp();
},
fontSizeDown() {
this.term.fontSizeDown();
},
},
};
</script>

View File

@ -0,0 +1,677 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
// Generated by:
//
// <!doctype html>
// <html>
// <meta charset="UTF-8" />
// <title>KEYBOARDEVENT KEY DUMP</title>
// <input name="key" id="input" />
// <script>
// var keyHistory = [];
// var targetProps = [
// "altKey",
// "char",
// "charCode",
// "code",
// "ctrlKey",
// "key",
// "keyCode",
// "location",
// "metaKey",
// "repeat",
// "shiftKey",
// "which",
// ]
// document.getElementById("input").addEventListener("keydown", function(e) {
// e.preventDefault();
// var ev = {};
// for (var i in targetProps) {
// ev[targetProps[i]] = e[targetProps[i]];
// }
// keyHistory.push([e.key, ev])
// document.getElementById("result").innerHTML = JSON.stringify(keyHistory);
// })
// </script>
// <div id="result">
// </div>
// </html>
export const consoleScreenKeys = [
{
title: "Function Keys",
keys: [
[
"F1",
{
altKey: false,
charCode: 0,
code: "F1",
ctrlKey: false,
key: "F1",
keyCode: 112,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 112,
},
],
[
"F2",
{
altKey: false,
charCode: 0,
code: "F2",
ctrlKey: false,
key: "F2",
keyCode: 113,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 113,
},
],
[
"F3",
{
altKey: false,
charCode: 0,
code: "F3",
ctrlKey: false,
key: "F3",
keyCode: 114,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 114,
},
],
[
"F4",
{
altKey: false,
charCode: 0,
code: "F4",
ctrlKey: false,
key: "F4",
keyCode: 115,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 115,
},
],
[
"F5",
{
altKey: false,
charCode: 0,
code: "F5",
ctrlKey: false,
key: "F5",
keyCode: 116,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 116,
},
],
[
"F6",
{
altKey: false,
charCode: 0,
code: "F6",
ctrlKey: false,
key: "F6",
keyCode: 117,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 117,
},
],
[
"F7",
{
altKey: false,
charCode: 0,
code: "F7",
ctrlKey: false,
key: "F7",
keyCode: 118,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 118,
},
],
[
"F8",
{
altKey: false,
charCode: 0,
code: "F8",
ctrlKey: false,
key: "F8",
keyCode: 119,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 119,
},
],
[
"F9",
{
altKey: false,
charCode: 0,
code: "F9",
ctrlKey: false,
key: "F9",
keyCode: 120,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 120,
},
],
[
"F10",
{
altKey: false,
charCode: 0,
code: "F10",
ctrlKey: false,
key: "F10",
keyCode: 121,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 121,
},
],
[
"F11",
{
altKey: false,
charCode: 0,
code: "F11",
ctrlKey: false,
key: "F11",
keyCode: 122,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 122,
},
],
[
"F12",
{
altKey: false,
charCode: 0,
code: "F12",
ctrlKey: false,
key: "F12",
keyCode: 123,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 123,
},
],
],
},
{
title: "Misc Keys",
keys: [
[
"Escape",
{
altKey: false,
charCode: 0,
code: "Escape",
ctrlKey: false,
key: "Escape",
keyCode: 27,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 27,
},
],
[
"Tab",
{
altKey: false,
charCode: 0,
code: "Tab",
ctrlKey: false,
key: "Tab",
keyCode: 9,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 9,
},
],
[
"Insert",
{
altKey: false,
charCode: 0,
code: "Insert",
ctrlKey: false,
key: "Insert",
keyCode: 45,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 45,
},
],
[
"Delete",
{
altKey: false,
charCode: 0,
code: "Delete",
ctrlKey: false,
key: "Delete",
keyCode: 46,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 46,
},
],
],
},
{
title: "Navigation Keys",
keys: [
[
"Home",
{
altKey: false,
charCode: 0,
code: "Home",
ctrlKey: false,
key: "Home",
keyCode: 36,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 36,
},
],
[
"End",
{
altKey: false,
charCode: 0,
code: "End",
ctrlKey: false,
key: "End",
keyCode: 35,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 35,
},
],
[
"Up " + String.fromCharCode(8593),
{
altKey: false,
charCode: 0,
code: "ArrowUp",
ctrlKey: false,
key: "ArrowUp",
keyCode: 38,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 38,
},
],
[
"Down " + String.fromCharCode(8595),
{
altKey: false,
charCode: 0,
code: "ArrowDown",
ctrlKey: false,
key: "ArrowDown",
keyCode: 40,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 40,
},
],
[
"Left " + String.fromCharCode(8592),
{
altKey: false,
charCode: 0,
code: "ArrowLeft",
ctrlKey: false,
key: "ArrowLeft",
keyCode: 37,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 37,
},
],
[
"Right " + String.fromCharCode(8594),
{
altKey: false,
charCode: 0,
code: "ArrowRight",
ctrlKey: false,
key: "ArrowRight",
keyCode: 39,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 39,
},
],
[
"Page Up " + String.fromCharCode(9652),
{
altKey: false,
charCode: 0,
code: "PageUp",
ctrlKey: false,
key: "PageUp",
keyCode: 33,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 33,
},
],
[
"Page Down " + String.fromCharCode(9662),
{
altKey: false,
charCode: 0,
code: "PageDown",
ctrlKey: false,
key: "PageDown",
keyCode: 34,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 34,
},
],
],
},
{
title: "Control Keys",
keys: [
[
"Ctrl+C",
{
altKey: false,
charCode: 0,
code: "KeyC",
ctrlKey: true,
key: "c",
keyCode: 67,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 67,
},
],
[
"Ctrl+Z",
{
altKey: false,
charCode: 0,
code: "KeyZ",
ctrlKey: true,
key: "z",
keyCode: 90,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 90,
},
],
[
"Ctrl+R",
{
altKey: false,
charCode: 0,
code: "KeyR",
ctrlKey: true,
key: "r",
keyCode: 82,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 82,
},
],
[
"Ctrl+L",
{
altKey: false,
charCode: 0,
code: "KeyL",
ctrlKey: true,
key: "l",
keyCode: 76,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 76,
},
],
[
"Ctrl+A",
{
altKey: false,
charCode: 0,
code: "KeyA",
ctrlKey: true,
key: "a",
keyCode: 65,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 65,
},
],
[
"Ctrl+E",
{
altKey: false,
charCode: 0,
code: "KeyE",
ctrlKey: true,
key: "e",
keyCode: 69,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 69,
},
],
[
"Ctrl+W",
{
altKey: false,
charCode: 0,
code: "KeyW",
ctrlKey: true,
key: "w",
keyCode: 87,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 87,
},
],
[
"Ctrl+U",
{
altKey: false,
charCode: 0,
code: "KeyU",
ctrlKey: true,
key: "u",
keyCode: 85,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 85,
},
],
[
"Ctrl+K",
{
altKey: false,
charCode: 0,
code: "KeyK",
ctrlKey: true,
key: "k",
keyCode: 75,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 75,
},
],
[
"Ctrl+D",
{
altKey: false,
charCode: 0,
code: "KeyD",
ctrlKey: true,
key: "d",
keyCode: 68,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 68,
},
],
[
"Ctrl+Q",
{
altKey: false,
charCode: 0,
code: "KeyQ",
ctrlKey: true,
key: "q",
keyCode: 81,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 81,
},
],
[
"Ctrl+X",
{
altKey: false,
charCode: 0,
code: "KeyX",
ctrlKey: true,
key: "x",
keyCode: 88,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 88,
},
],
[
"Ctrl+T",
{
altKey: false,
charCode: 0,
code: "KeyT",
ctrlKey: true,
key: "t",
keyCode: 84,
location: 0,
metaKey: false,
repeat: false,
shiftKey: false,
which: 84,
},
],
],
},
];

75
widgets/screens.css Normal file
View File

@ -0,0 +1,75 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#home-content.active {
min-height: 0;
}
#home-content > .screen {
display: flex;
justify-content: start;
flex-direction: column;
font-size: 1em;
overflow: hidden;
flex: auto;
}
#home-content > .screen.screen-inactive {
flex: 0 0 0;
}
#home-content > .screen > .screen-error {
display: block;
padding: 10px;
background: #b44;
color: #fff;
font-size: 0.75em;
flex: 0 0;
}
#home-content > .screen > .screen-error.screen-error-level-error {
background: #b44;
}
#home-content > .screen > .screen-error.screen-error-level-warning {
background: #b82;
}
#home-content > .screen > .screen-error.screen-error-level-info {
background: #28b;
}
#home-content > .screen > .screen-screen {
flex: auto;
padding: 0;
margin: 0;
position: relative;
min-height: 0;
}
#home-content > .screen > .screen-screen > .screen-content {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
position: relative;
overflow: hidden;
}

107
widgets/screens.vue Normal file
View File

@ -0,0 +1,107 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<main :class="{ active: screens.length > 0 }">
<slot v-if="screens.length <= 0"></slot>
<div
v-for="(screenInfo, idx) in screens"
:key="screenInfo.id"
:class="{ 'screen-inactive': screen !== idx }"
class="screen"
>
<h1 style="display: none">Main Interface</h1>
<div
v-if="screenInfo.indicator.message.length > 0"
class="screen-error"
:class="'screen-error-level-' + screenInfo.indicator.level"
>
{{ screenInfo.indicator.message }}
</div>
<div class="screen-screen">
<component
:is="getComponent(screenInfo.ui)"
:active="screen === idx"
:control="screenInfo.control"
:change="screenInfo.indicator"
:toolbar="screenInfo.toolbar"
:view-port="viewPort"
:style="'background-color: ' + screenInfo.control.activeColor()"
class="screen-content"
@stopped="stopped(idx, $event)"
@warning="warning(idx, $event)"
@info="info(idx, $event)"
@updated="updated(idx)"
></component>
</div>
</div>
</main>
</template>
<script>
import ConsoleScreen from "./screen_console.vue";
import "./screens.css";
export default {
components: {
ConsoleScreen,
},
props: {
screen: {
type: Number,
default: 0,
},
screens: {
type: Array,
default: () => [],
},
viewPort: {
type: Object,
default: () => {},
},
},
methods: {
getComponent(ui) {
switch (ui) {
case "Console":
return "ConsoleScreen";
default:
throw new Error("Unknown UI: " + ui);
}
},
stopped(index, stopErr) {
this.$emit("stopped", index, stopErr);
},
warning(index, msg) {
this.$emit("warning", index, msg);
},
info(index, msg) {
this.$emit("info", index, msg);
},
updated(index) {
this.$emit("updated", index);
},
},
};
</script>

221
widgets/status.css Normal file
View File

@ -0,0 +1,221 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#conn-status {
z-index: 999999;
top: 40px;
left: 96px;
display: none;
width: 500px;
background: #262626;
}
#conn-status .window-frame {
max-height: calc(100vh - 40px);
overflow: auto;
}
#conn-status:before {
left: 30px;
}
@media (max-width: 768px) {
#conn-status {
left: 20px;
right: 20px;
width: auto;
}
#conn-status:before {
left: 91px;
}
}
#conn-status.display {
display: block;
}
#conn-status h1 {
padding: 15px 15px 10px 15px;
background: #a56;
}
#conn-status-info {
padding: 0 15px 15px 15px;
font-size: 0.9em;
line-height: 1.5;
background: #a56;
}
#conn-status.green:before {
background: #287;
}
#conn-status.green h1 {
color: #6ba;
background: #287;
}
#conn-status.green #conn-status-info {
background: #287;
}
#conn-status.yellow:before {
background: #da0;
}
#conn-status.yellow h1 {
color: #fff;
background: #da0;
}
#conn-status.yellow #conn-status-info {
background: #da0;
}
#conn-status.orange:before {
background: #b73;
}
#conn-status.orange h1 {
color: #fff;
background: #b73;
}
#conn-status.orange #conn-status-info {
background: #b73;
}
#conn-status.red:before {
background: #a33;
}
#conn-status.red h1 {
color: #fff;
background: #a33;
}
#conn-status.red #conn-status-info {
background: #a33;
}
.conn-status-chart {
}
.conn-status-chart > .counters {
width: 100%;
overflow: auto;
margin-bottom: 10px;
}
.conn-status-chart > .counters > .counter {
width: 50%;
display: block;
float: left;
margin: 10px 0;
text-align: center;
}
.conn-status-chart > .counters > .counter .name {
font-size: 0.8em;
color: #777;
text-transform: uppercase;
font-weight: bold;
letter-spacing: 1px;
}
.conn-status-chart > .counters > .counter .value {
font-size: 1.5em;
font-weight: lighter;
}
.conn-status-chart > .counters > .counter .value span {
font-size: 0.7em;
}
.conn-status-chart > .chart {
margin: 0 10px;
}
.conn-status-chart > .chart g {
fill: none;
stroke-width: 7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.conn-status-chart > .chart .zero {
stroke: #3a3a3a;
stroke-width: 3px;
}
.conn-status-chart > .chart .expired {
stroke: #3a3a3a;
}
#conn-status-delay {
padding: 15px 0;
background: #292929;
}
#conn-status-delay > .counters > .counter {
width: 100%;
float: none;
}
#conn-status-delay-chart-background {
--color-start: #e43989;
--color-stop: #9a5fca;
}
#conn-status-delay-chart > g {
fill: none;
stroke: url(#conn-status-delay-chart-background) #2a6;
}
#conn-status-traffic {
padding: 15px 0;
}
#conn-status-traffic-chart-in-background {
--color-start: #0287a8;
--color-stop: #06e7b6;
}
#conn-status-traffic-chart-in > g {
stroke: url(#conn-status-traffic-chart-in-background) #2a6;
}
#conn-status-traffic-chart-out-background {
--color-start: #e46226;
--color-stop: #da356c;
}
#conn-status-traffic-chart-out > g {
stroke: url(#conn-status-traffic-chart-out-background) #2a6;
}
#conn-status-close {
/* ID mainly use for document.getElementById */
cursor: pointer;
right: 10px;
top: 20px;
}

251
widgets/status.vue Normal file
View File

@ -0,0 +1,251 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<window
id="conn-status"
flash-class="home-window-display"
:display="display"
@display="$emit('display', $event)"
>
<h1 class="window-title">Connection status</h1>
<div id="conn-status-info">
{{ status.description }}
</div>
<div id="conn-status-delay" class="conn-status-chart">
<div class="counters">
<div class="counter">
<div class="name">Delay</div>
<div
class="value"
v-html="$options.filters.mSecondString(status.delay)"
></div>
</div>
</div>
<div class="chart">
<chart
id="conn-status-delay-chart"
:width="480"
:height="50"
type="Bar"
:enabled="display"
:values="status.delayHistory"
>
<defs>
<linearGradient
id="conn-status-delay-chart-background"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="0"
y2="100%"
>
<stop stop-color="var(--color-start)" offset="0%" />
<stop stop-color="var(--color-stop)" offset="100%" />
</linearGradient>
</defs>
</chart>
</div>
</div>
<div id="conn-status-traffic" class="conn-status-chart">
<div class="counters">
<div class="counter">
<div class="name">Inbound</div>
<div
class="value"
v-html="$options.filters.bytePerSecondString(status.inbound)"
></div>
</div>
<div class="counter">
<div class="name">Outbound</div>
<div
class="value"
v-html="$options.filters.bytePerSecondString(status.outbound)"
></div>
</div>
</div>
<div class="chart">
<chart
id="conn-status-traffic-chart-in"
:width="480"
:height="25"
type="Bar"
:max="inoutBoundMax"
:enabled="display"
:values="status.inboundHistory"
@max="inboundMaxColUpdated"
>
<defs>
<linearGradient
id="conn-status-traffic-chart-in-background"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="0"
y2="100%"
>
<stop stop-color="var(--color-start)" offset="0%" />
<stop stop-color="var(--color-stop)" offset="100%" />
</linearGradient>
</defs>
</chart>
</div>
<div class="chart">
<chart
id="conn-status-traffic-chart-out"
:width="480"
:height="25"
type="UpsideDownBar"
:max="inoutBoundMax"
:enabled="display"
:values="status.outboundHistory"
@max="outboundMaxColUpdated"
>
<defs>
<linearGradient
id="conn-status-traffic-chart-out-background"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="0"
y2="100%"
>
<stop stop-color="var(--color-start)" offset="0%" />
<stop stop-color="var(--color-stop)" offset="100%" />
</linearGradient>
</defs>
</chart>
</div>
</div>
</window>
</template>
<script>
/* eslint vue/attribute-hyphenation: 0 */
import "./status.css";
import Window from "./window.vue";
import Chart from "./chart.vue";
export default {
components: {
window: Window,
chart: Chart,
},
filters: {
bytePerSecondString(n) {
const bNames = ["byte/s", "kib/s", "mib/s", "gib/s", "tib/s"];
let remain = n,
nUnit = bNames[0];
for (let i in bNames) {
nUnit = bNames[i];
if (remain < 1024) {
break;
}
remain /= 1024;
}
return (
Number(remain.toFixed(2)).toLocaleString() +
" <span>" +
nUnit +
"</span>"
);
},
mSecondString(n) {
if (n < 0) {
return "??";
}
const bNames = ["ms", "s", "m"];
let remain = n,
nUnit = bNames[0];
for (let i in bNames) {
nUnit = bNames[i];
if (remain < 1000) {
break;
}
remain /= 1000;
}
return (
Number(remain.toFixed(2)).toLocaleString() +
" <span>" +
nUnit +
"</span>"
);
},
},
props: {
display: {
type: Boolean,
default: false,
},
status: {
type: Object,
default: () => {
return {
description: "",
delay: 0,
delayHistory: [],
inbound: 0,
inboundHistory: [],
outbound: 0,
outboundHistory: [],
};
},
},
},
data() {
return {
inoutBoundMax: 0,
inboundMax: 0,
outboundMax: 0,
};
},
methods: {
inboundMaxColUpdated(d) {
this.inboundMax = d;
this.inoutBoundMax =
this.inboundMax > this.outboundMax ? this.inboundMax : this.outboundMax;
},
outboundMaxColUpdated(d) {
this.outboundMax = d;
this.inoutBoundMax =
this.inboundMax > this.outboundMax ? this.inboundMax : this.outboundMax;
},
},
};
</script>

116
widgets/tab_list.vue Normal file
View File

@ -0,0 +1,116 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<ul :id="id" :class="tabsClass">
<li
v-for="(tabInfo, idx) in tabs"
:key="tabInfo.id"
:class="{
active: tab === idx,
error:
tabInfo.indicator.message.length > 0 &&
tabInfo.indicator.level === 'error',
updated: tabInfo.indicator.updated && tab !== idx,
}"
:style="
'background: ' +
(tab === idx ? tabInfo.control.activeColor() : tabInfo.control.color())
"
@click.self="switchTab(idx)"
>
<span class="title" :title="tabInfo.name" @click="switchTab(idx)">
<span
class="type"
:title="tabInfo.info.name()"
:style="'background: ' + tabInfo.info.color()"
>
{{ tabInfo.info.name()[0] }}
</span>
{{ tabInfo.name }}
</span>
<span class="icon icon-close icon-close1" @click="closeAt(idx)"></span>
</li>
</ul>
</template>
<script>
export default {
props: {
id: {
type: String,
default: "",
},
tab: {
type: Number,
default: -1,
},
tabs: {
type: Array,
default: () => [],
},
tabsClass: {
type: String,
default: "",
},
},
watch: {
tab(newVal) {
this.switchTabTo(newVal);
},
tabs(newVal) {
if (newVal.length > this.tab) {
return;
}
this.switchTabTo(newVal.length - 1);
},
},
methods: {
switchTabTo(index) {
if (index < 0 || index >= this.tabs.length) {
return;
}
if (this.tab == index) {
return;
}
this.$emit("current", index);
},
switchTab(index) {
if (index < 0 || index >= this.tabs.length) {
return;
}
if (this.tab === index) {
this.$emit("retap", index);
return;
}
return this.switchTabTo(index);
},
closeAt(index) {
this.$emit("close", index);
},
},
};
</script>

148
widgets/tab_window.css Normal file
View File

@ -0,0 +1,148 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";
#tab-window {
z-index: 999999;
top: 40px;
right: 0px;
display: none;
width: 400px;
background: #333;
}
#tab-window .window-frame {
max-height: calc(100vh - 40px);
overflow: auto;
}
#tab-window:before {
right: 19px;
background: #333;
}
@media (max-width: 768px) {
#tab-window {
width: 80%;
}
}
#tab-window.display {
display: block;
}
#tab-window-close {
cursor: pointer;
right: 10px;
top: 20px;
color: #999;
}
#tab-window h1 {
padding: 15px 15px 0 15px;
margin-bottom: 10px;
color: #999;
}
#tab-window-list > li > .lst-wrap {
padding: 10px 20px;
cursor: pointer;
}
#tab-window-list > li {
border-bottom: none;
}
#tab-window-tabs {
flex: auto;
overflow: hidden;
}
#tab-window-tabs > li {
display: flex;
position: relative;
padding: 15px;
opacity: 0.5;
color: #999;
cursor: pointer;
}
#tab-window-tabs > li::after {
content: " ";
display: block;
position: absolute;
top: 5px;
bottom: 5px;
left: 0;
width: 0;
transition: all 0.1s linear;
transition-property: width, top, bottom;
}
#tab-window-tabs > li.active::after {
top: 0;
bottom: 0;
}
#tab-window-tabs > li.updated::after {
background: #fff3;
width: 5px;
}
#tab-window-tabs > li.error::after {
background: #d55;
width: 5px;
}
#tab-window-tabs > li > span.title {
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
}
#tab-window-tabs > li > span.title > span.type {
display: inline-block;
font-size: 0.85em;
font-weight: bold;
margin-right: 3px;
text-transform: uppercase;
color: #fff;
background: #222;
padding: 1px 4px;
border-radius: 2px;
}
#tab-window-tabs > li > .icon-close {
display: block;
position: absolute;
top: 50%;
right: 10px;
margin-top: -5px;
color: #fff6;
}
#tab-window-tabs > li.active {
color: #fff;
opacity: 1;
}
#tab-window-tabs > li.active > span.title {
padding-right: 20px;
}

80
widgets/tab_window.vue Normal file
View File

@ -0,0 +1,80 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<window
id="tab-window"
flash-class="home-window-display"
:display="display"
@display="$emit('display', $event)"
>
<h1 class="window-title">Opened tabs</h1>
<tab-list
id="tab-window-tabs"
:tab="tab"
:tabs="tabs"
:tabs-class="tabsClass"
@current="$emit('current', $event)"
@retap="$emit('retap', $event)"
@close="$emit('close', $event)"
></tab-list>
</window>
</template>
<script>
import "./tab_window.css";
import Window from "./window.vue";
import TabList from "./tab_list.vue";
export default {
components: {
window: Window,
"tab-list": TabList,
},
props: {
display: {
type: Boolean,
default: false,
},
tab: {
type: Number,
default: -1,
},
tabs: {
type: Array,
default: () => [],
},
tabsClass: {
type: String,
default: "",
},
},
watch: {
tabs(newV) {
if (newV.length > 0) {
return;
}
this.$emit("display", false);
},
},
};
</script>

77
widgets/tabs.vue Normal file
View File

@ -0,0 +1,77 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<div :id="id">
<tab-list
:id="id + '-tabs'"
:tab="tab"
:tabs="tabs"
:tabs-class="tabsClass"
@current="$emit('current', $event)"
@retap="$emit('retap', $event)"
@close="$emit('close', $event)"
></tab-list>
<a
v-if="tabs.length > 0"
:id="id + '-list'"
:class="listTriggerClass"
href="javascript:;"
@click="showList"
></a>
</div>
</template>
<script>
import TabList from "./tab_list.vue";
export default {
components: {
"tab-list": TabList,
},
props: {
id: {
type: String,
default: "",
},
tab: {
type: Number,
default: -1,
},
tabs: {
type: Array,
default: () => [],
},
tabsClass: {
type: String,
default: "",
},
listTriggerClass: {
type: String,
default: "",
},
},
methods: {
showList() {
this.$emit("list", this.tabs);
},
},
};
</script>

20
widgets/window.css Normal file
View File

@ -0,0 +1,20 @@
/*
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
*/
@charset "utf-8";

77
widgets/window.vue Normal file
View File

@ -0,0 +1,77 @@
<!--
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
-->
<template>
<div
:id="id"
class="window window1"
:class="[{ display: displaying }, { [flashClass]: displaying }]"
>
<div class="window-frame">
<slot />
</div>
<span
:id="id + '-close'"
class="window-close icon icon-close1"
@click="hide"
/>
</div>
</template>
<script>
export default {
props: {
id: {
type: String,
default: "",
},
display: {
type: Boolean,
default: false,
},
flashClass: {
type: String,
default: "",
},
},
data() {
return {
displaying: false,
};
},
watch: {
display(newVal) {
newVal ? this.show() : this.hide();
},
},
methods: {
show() {
this.displaying = true;
this.$emit("display", this.displaying);
},
hide() {
this.displaying = false;
this.$emit("display", this.displaying);
},
},
};
</script>

54
xhr.js Normal file
View File

@ -0,0 +1,54 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2023 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/>.
function send(method, url, headers) {
return new Promise((res, rej) => {
let authReq = new XMLHttpRequest();
authReq.addEventListener("readystatechange", () => {
if (authReq.readyState !== authReq.DONE) {
return;
}
res(authReq);
});
authReq.addEventListener("error", (e) => {
rej(e);
});
authReq.addEventListener("timeout", (e) => {
rej(e);
});
authReq.open(method, url, true);
for (let h in headers) {
authReq.setRequestHeader(h, headers[h]);
}
authReq.send();
});
}
export function get(url, headers) {
return send("GET", url, headers);
}
export function options(url, headers) {
return send("OPTIONS", url, headers);
}