Implemented the host name auto suggestion, and added Preset feature

This commit is contained in:
NI
2020-02-07 18:05:44 +08:00
parent 0a930d1345
commit 67c99e3092
22 changed files with 1582 additions and 332 deletions

View File

@@ -54,7 +54,9 @@ ENV SSHWIFTY_HOSTNAME= \
SSHWIFTY_TLSCERTIFICATEFILE= \
SSHWIFTY_TLSCERTIFICATEKEYFILE= \
SSHWIFTY_DOCKER_TLSCERT= \
SSHWIFTY_DOCKER_TLSCERTKEY=
SSHWIFTY_DOCKER_TLSCERTKEY= \
SSHWIFTY_PRESETS= \
SSHWIFTY_ONLYALLOWPRESETREMOTES=
COPY --from=builder /sshwifty /
COPY . /sshwifty-src
RUN set -ex && \

View File

@@ -50,9 +50,10 @@ $ docker run --detach \
```
The `domain.crt` and `domain.key` must be valid TLS certificate and key file
located on the machine which the `docker run` command will be executed.
located on the same machine which the `docker run` command will be executed
upon.
[Docker]: https://www.docker.com
[docker]: https://www.docker.com
### Compile from source code (Recommanded if you're a developer)
@@ -75,7 +76,7 @@ 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 encountered any compile/build related problem.
Please refer to it when you encountered any compile/build related issue.
### Deploy on the cloud
@@ -99,8 +100,8 @@ 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 `SSHWIFTY_CONFIG`
environment variable. For example:
You can also specify your own configuration file by setting up `SSHWIFTY_CONFIG`
environment variable before start the software. For example:
```
$ SSHWIFTY_CONFIG=./sshwifty.conf.json ./sshwifty
@@ -188,7 +189,66 @@ Here is all the options of a configuration file:
"InitialTimeout": 3,
.....
}
]
],
// Remote Presets, the operate 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" table on the Connector
// window
"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
"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
"Private Key": "pre-defined-private-key",
// Data for predefined Authentication field. Valid values is what
// displayed on the page (Password, Private Key, None)
"Authentication": "Password",
}
},
{
"Title": "Endpoint Telnet",
"Type": "Telnet",
"Host": "endpoint.vaguly.com",
"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
}
```
@@ -215,6 +275,8 @@ SSHWIFTY_WRITEELAY
SSHWIFTY_LISTENINTERFACE
SSHWIFTY_TLSCERTIFICATEFILE
SSHWIFTY_TLSCERTIFICATEKEYFILE
SSHWIFTY_PRESETS
SSHWIFTY_ONLYALLOWPRESETREMOTES
```
The option they represented is corresponded to their counterparts in the
@@ -264,8 +326,8 @@ Code of this project is licensed under AGPL, see [LICENSE.md] for detail.
Third-party components used by this project are licensed under their respective
licenses. See [DEPENDENCIES.md] for dependencies used by this project.
[LICENSE.md]: LICENSE.md
[DEPENDENCIES.md]: DEPENDENCIES.md
[license.md]: LICENSE.md
[dependencies.md]: DEPENDENCIES.md
## Contribute
@@ -274,10 +336,10 @@ Sorry.
Upon release (Which is then you're able to read this file), this project will
enter _maintaining_ state, which includes doing bug fix and security updates.
Adding new features however, is not a part of the state.
_Adding new features however, is not a part of the state_.
Please do not send pull request. If you need new feature, fork it, and maintain
it like one of your own project.
Please do not send pull request. If you need new feature, fork it, add it by
yourself, and maintain it like one of your own project.
(Notice: Typo, grammar error or invalid use of language in the source code and
document is categorized as bug, please report them if you found any. Thank you!)

View File

@@ -120,21 +120,33 @@ func (s Server) Verify() error {
return nil
}
// Preset contains data of a static remote host
type Preset struct {
Title string
Type string
Host string
Meta map[string]string
}
// Configuration contains configuration of the application
type Configuration struct {
HostName string
SharedKey string
Dialer network.Dial
DialTimeout time.Duration
Servers []Server
HostName string
SharedKey string
Dialer network.Dial
DialTimeout time.Duration
Servers []Server
Presets []Preset
OnlyAllowPresetRemotes bool
}
// Common settings shared by mulitple servers
type Common struct {
HostName string
SharedKey string
Dialer network.Dial
DialTimeout time.Duration
HostName string
SharedKey string
Dialer network.Dial
DialTimeout time.Duration
Presets []Preset
OnlyAllowPresetRemotes bool
}
// Verify verifies current setting
@@ -164,6 +176,16 @@ func (c Configuration) Common() Common {
dialer = network.TCPDial()
}
if c.OnlyAllowPresetRemotes {
accessList := make(network.AllowedHosts, len(c.Presets))
for _, k := range c.Presets {
accessList[k.Host] = struct{}{}
}
dialer = network.AccessControlDial(accessList, dialer)
}
dialTimeout := c.DialTimeout
if dialTimeout <= 1*time.Second {
@@ -171,10 +193,12 @@ func (c Configuration) Common() Common {
}
return Common{
HostName: c.HostName,
SharedKey: c.SharedKey,
Dialer: c.Dialer,
DialTimeout: c.DialTimeout,
HostName: c.HostName,
SharedKey: c.SharedKey,
Dialer: dialer,
DialTimeout: c.DialTimeout,
Presets: c.Presets,
OnlyAllowPresetRemotes: c.OnlyAllowPresetRemotes,
}
}

View File

@@ -18,6 +18,7 @@
package configuration
import (
"encoding/json"
"fmt"
"os"
"strconv"
@@ -102,12 +103,27 @@ func Enviro() Loader {
TLSCertificateKeyFile: parseEviro("SSHWIFTY_TLSCERTIFICATEKEYFILE"),
}
presets := make([]Preset, 0, 16)
presetStr := strings.TrimSpace(parseEviro("SSHWIFTY_PRESETS"))
if len(presetStr) > 0 {
jErr := json.Unmarshal([]byte(presetStr), &presets)
if jErr != nil {
return enviroTypeName, Configuration{}, fmt.Errorf(
"Invalid \"SSHWIFTY_PRESETS\": %s", jErr)
}
}
return enviroTypeName, Configuration{
HostName: cfg.HostName,
SharedKey: cfg.SharedKey,
Dialer: dialer,
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
Servers: []Server{cfgSer.build()},
Presets: presets,
OnlyAllowPresetRemotes: len(
parseEviro("SSHWIFTY_ONLYALLOWPRESETREMOTES")) > 0,
}, nil
}
}

View File

@@ -27,9 +27,8 @@ import (
"strings"
"time"
"github.com/niruix/sshwifty/application/network"
"github.com/niruix/sshwifty/application/log"
"github.com/niruix/sshwifty/application/network"
)
const (
@@ -78,14 +77,49 @@ func (f *fileCfgServer) build() Server {
}
}
type fileCfgPreset struct {
Title string
Type string
Host string
Meta map[string]string
}
func (f fileCfgPreset) build() Preset {
return Preset{
Title: f.Title,
Type: strings.TrimSpace(f.Type),
Host: f.Host,
Meta: f.Meta,
}
}
type fileCfgCommon struct {
HostName string // Host name
SharedKey string // Shared key, empty to enable public access
DialTimeout int // DialTimeout, min 5s
Socks5 string // Socks5 server address, optional
Socks5User string // Login user for socks5 server, optional
Socks5Password string // Login pass for socks5 server, optional
Servers []*fileCfgServer // Servers
// Host name
HostName string
// Shared key, empty to enable public access
SharedKey string
// DialTimeout, min 5s
DialTimeout int
// Socks5 server address, optional
Socks5 string
// Login user for socks5 server, optional
Socks5User string
// Login pass for socks5 server, optional
Socks5Password string
// Servers
Servers []*fileCfgServer
// Remotes
Presets []*fileCfgPreset
// Allow predefined remotes only
OnlyAllowPresetRemotes bool
}
func (f fileCfgCommon) build() (fileCfgCommon, network.Dial, error) {
@@ -111,13 +145,15 @@ func (f fileCfgCommon) build() (fileCfgCommon, network.Dial, error) {
}
return fileCfgCommon{
HostName: f.HostName,
SharedKey: f.SharedKey,
DialTimeout: dialTimeout,
Socks5: f.Socks5,
Socks5User: f.Socks5User,
Socks5Password: f.Socks5Password,
Servers: f.Servers,
HostName: f.HostName,
SharedKey: f.SharedKey,
DialTimeout: dialTimeout,
Socks5: f.Socks5,
Socks5User: f.Socks5User,
Socks5Password: f.Socks5Password,
Servers: f.Servers,
Presets: f.Presets,
OnlyAllowPresetRemotes: f.OnlyAllowPresetRemotes,
}, dialer, nil
}
@@ -151,12 +187,21 @@ func loadFile(filePath string) (string, Configuration, error) {
servers[i] = finalCfg.Servers[i].build()
}
presets := make([]Preset, len(finalCfg.Presets))
for i := range presets {
presets[i] = finalCfg.Presets[i].build()
}
return fileTypeName, Configuration{
HostName: finalCfg.HostName,
SharedKey: finalCfg.SharedKey,
Dialer: dialer,
DialTimeout: time.Duration(finalCfg.DialTimeout) * time.Second,
Servers: servers,
HostName: finalCfg.HostName,
SharedKey: finalCfg.SharedKey,
Dialer: dialer,
DialTimeout: time.Duration(finalCfg.DialTimeout) *
time.Second,
Servers: servers,
Presets: presets,
OnlyAllowPresetRemotes: cfg.OnlyAllowPresetRemotes,
}, nil
}

View File

@@ -46,6 +46,7 @@ type handler struct {
logger log.Logger
homeCtl home
socketCtl socket
socketVerifyCtl socketVerification
}
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -80,6 +81,8 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case "/sshwifty/socket":
err = serveController(h.socketCtl, w, r, clientLogger)
case "/sshwifty/socket/verify":
err = serveController(h.socketVerifyCtl, w, r, clientLogger)
case "/robots.txt":
err = serveStaticCacheData(
@@ -155,12 +158,15 @@ func Builder(cmds command.Commands) server.HandlerBuilder {
cfg configuration.Server,
logger log.Logger,
) http.Handler {
socketCtl := newSocketCtl(commonCfg, cfg, cmds)
return handler{
hostNameChecker: commonCfg.HostName + ":",
commonCfg: commonCfg,
logger: logger,
homeCtl: home{},
socketCtl: newSocketCtl(commonCfg, cfg, cmds),
socketCtl: socketCtl,
socketVerifyCtl: newSocketVerification(socketCtl, cfg, commonCfg),
}
}
}

View File

@@ -162,54 +162,6 @@ func (s socket) Options(
return nil
}
func (s socket) setServerConfigHeader(hd *http.Header) {
hd.Add("X-Heartbeat",
strconv.FormatFloat(s.serverCfg.HeartbeatTimeout.Seconds(), 'g', 2, 64))
hd.Add("X-Timeout",
strconv.FormatFloat(s.serverCfg.ReadTimeout.Seconds(), 'g', 2, 64))
}
func (s socket) Head(
w http.ResponseWriter, r *http.Request, l log.Logger) error {
key := r.Header.Get("X-Key")
hd := w.Header()
if len(key) <= 0 {
hd.Add("X-Key", s.randomKey)
if len(s.commonCfg.SharedKey) <= 0 {
s.setServerConfigHeader(&hd)
return nil
}
return ErrSocketAuthFailed
}
if len(key) > 64 {
return ErrSocketAuthFailed
}
// Delay the brute force attack. Use it with connection limits (via
// iptables or nginx etc)
time.Sleep(500 * time.Millisecond)
decodedKey, decodedKeyErr := base64.StdEncoding.DecodeString(key)
if decodedKeyErr != nil {
return NewError(http.StatusBadRequest, decodedKeyErr.Error())
}
if !hmac.Equal(s.authKey, decodedKey) {
return ErrSocketAuthFailed
}
hd.Add("X-Key", s.randomKey)
s.setServerConfigHeader(&hd)
return nil
}
func (s socket) buildWSFetcher(c *websocket.Conn) rw.FetchReaderFetcher {
return func() ([]byte, error) {
for {

View File

@@ -0,0 +1,138 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2020 Rui NI <nirui@gmx.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/>.
package controller
import (
"crypto/hmac"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/niruix/sshwifty/application/configuration"
"github.com/niruix/sshwifty/application/log"
)
type socketVerification struct {
socket
heartbeat string
timeout string
configRspBody []byte
}
type socketRemotePreset struct {
Title string `json:"title"`
Type string `json:"type"`
Host string `json:"host"`
Meta map[string]string `json:"meta"`
}
func buildAccessConfigRespondBody(remotes []configuration.Preset) []byte {
presets := make([]socketRemotePreset, len(remotes))
for i := range presets {
presets[i] = socketRemotePreset{
Title: remotes[i].Title,
Type: remotes[i].Type,
Host: remotes[i].Host,
Meta: remotes[i].Meta,
}
}
mData, mErr := json.Marshal(presets)
if mErr != nil {
panic(fmt.Errorf("Unable to marshal remote data: %s", mErr))
}
return mData
}
func newSocketVerification(
s socket,
srvCfg configuration.Server,
commCfg configuration.Common,
) socketVerification {
return socketVerification{
socket: s,
heartbeat: strconv.FormatFloat(
srvCfg.HeartbeatTimeout.Seconds(), 'g', 2, 64),
timeout: strconv.FormatFloat(
srvCfg.ReadTimeout.Seconds(), 'g', 2, 64),
configRspBody: buildAccessConfigRespondBody(commCfg.Presets),
}
}
func (s socketVerification) setServerConfigRespond(
hd *http.Header, w http.ResponseWriter) {
hd.Add("X-Heartbeat", s.heartbeat)
hd.Add("X-Timeout", s.timeout)
if s.commonCfg.OnlyAllowPresetRemotes {
hd.Add("X-OnlyAllowPresetRemotes", "yes")
}
hd.Add("Content-Type", "text/json; charset=utf-8")
w.Write(s.configRspBody)
}
func (s socketVerification) Get(
w http.ResponseWriter, r *http.Request, l log.Logger) error {
hd := w.Header()
key := r.Header.Get("X-Key")
if len(key) <= 0 {
hd.Add("X-Key", s.randomKey)
if len(s.commonCfg.SharedKey) <= 0 {
s.setServerConfigRespond(&hd, w)
return nil
}
return ErrSocketAuthFailed
}
if len(key) > 64 {
return ErrSocketAuthFailed
}
// Delay the brute force attack. Use it with connection limits (via
// iptables or nginx etc)
time.Sleep(500 * time.Millisecond)
decodedKey, decodedKeyErr := base64.StdEncoding.DecodeString(key)
if decodedKeyErr != nil {
return NewError(http.StatusBadRequest, decodedKeyErr.Error())
}
if !hmac.Equal(s.authKey, decodedKey) {
return ErrSocketAuthFailed
}
hd.Add("X-Key", s.randomKey)
s.setServerConfigRespond(&hd, w)
return nil
}

View File

@@ -0,0 +1,61 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2020 Rui NI <nirui@gmx.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/>.
package network
import (
"errors"
"net"
"time"
)
// Errors
var (
ErrAccessControlDialTargetHostNotAllowed = errors.New(
"Refuse to connect to specified target host due to outgoing " +
"restriction")
)
// AllowedHosts contains a map of allowed remote hosts
type AllowedHosts map[string]struct{}
// Allowed returns whether or not given host is allowed
func (a AllowedHosts) Allowed(host string) bool {
_, ok := a[host]
return ok
}
// AllowedHost returns whether or not give host is allowed
type AllowedHost interface {
Allowed(host string) bool
}
// AccessControlDial creates an access controlled Dial
func AccessControlDial(allowed AllowedHost, dial Dial) Dial {
return func(
network string,
address string,
timeout time.Duration,
) (net.Conn, error) {
if !allowed.Allowed(address) {
return nil, ErrAccessControlDialTargetHostNotAllowed
}
return dial(network, address, timeout)
}
}

View File

@@ -18,5 +18,36 @@
"TLSCertificateFile": "",
"TLSCertificateKeyFile": ""
}
]
],
"Presets": [
{
"Title": "SDF.org Unix Shell",
"Type": "SSH",
"Host": "sdf.org:22",
"Meta": {
"Encoding": "utf-8",
"Authentication": "Password"
}
},
{
"Title": "My own super secure server",
"Type": "SSH",
"Host": "localhost",
"Meta": {
"User": "root",
"Encoding": "utf-8",
"Private Key": "--------- BEGIN RSA PRIVATE KEY ---------",
"Authentication": "Private Key"
}
},
{
"Title": "My own super expensive router",
"Type": "Telnet",
"Host": "10.0.0.1",
"Meta": {
"Encoding": "ibm866"
}
}
],
"OnlyAllowPresetRemotes": false
}

116
ui/app.js
View File

@@ -15,28 +15,24 @@
// 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 "./common.css";
import "./app.css";
import "./landing.css";
import { Socket } from "./socket.js";
import Vue from "vue";
import Home from "./home.vue";
import "./app.css";
import Auth from "./auth.vue";
import Loading from "./loading.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 { Controls } from "./commands/controls.js";
import { Color as ControlColor } from "./commands/color.js";
import * as telnetctl from "./control/telnet.js";
import "./common.css";
import * as sshctl from "./control/ssh.js";
import * as xhr from "./xhr.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 xhr from "./xhr.js";
const backendQueryRetryDelay = 2000;
@@ -52,6 +48,8 @@ const mainTemplate = `
:connection="socket"
:controls="controls"
:commands="commands"
:preset-data="presetData.presets"
:restricted-to-presets="presetData.restricted"
@navigate-to="changeURLHash"
@tab-opened="tabOpened"
@tab-closed="tabClosed"
@@ -66,6 +64,7 @@ const mainTemplate = `
`.trim();
const socksInterface = "/sshwifty/socket";
const socksVerificationInterface = socksInterface + "/verify";
function startApp(rootEl) {
const pageTitle = document.title;
@@ -93,6 +92,10 @@ function startApp(rootEl) {
: "",
page: "loading",
key: "",
presetData: {
presets: new Presets([]),
restricted: false
},
authErr: "",
loadErr: "",
socket: null,
@@ -163,6 +166,18 @@ function startApp(rootEl) {
heartbeatInterval * 1000
);
},
executeHomeApp(authResult, key) {
this.presetData = {
presets: new Presets(JSON.parse(authResult.data)),
restricted: authResult.onlyAllowPresetRemotes
};
this.socket = this.buildSocket(
key,
authResult.timeout,
authResult.heartbeat
);
this.page = "app";
},
async tryInitialAuth() {
try {
let result = await this.doAuth("");
@@ -188,35 +203,30 @@ function startApp(rootEl) {
switch (result.result) {
case 200:
this.socket = this.buildSocket(
{
data: result.key,
async fetch() {
if (this.data) {
let dKey = this.data;
this.executeHomeApp(result, {
data: result.key,
async fetch() {
if (this.data) {
let dKey = this.data;
this.data = null;
this.data = null;
return dKey;
}
let result = await self.doAuth("");
if (result.result !== 200) {
throw new Error(
"Unable to fetch key from remote, unexpected " +
"error code: " +
result.result
);
}
return result.key;
return dKey;
}
},
result.timeout,
result.heartbeat
);
this.page = "app";
let result = await self.doAuth("");
if (result.result !== 200) {
throw new Error(
"Unable to fetch key from remote, unexpected " +
"error code: " +
result.result
);
}
return result.key;
}
});
break;
case 403:
@@ -251,7 +261,7 @@ function startApp(rootEl) {
? null
: await this.getSocketAuthKey(privateKey, this.key);
let h = await xhr.head(socksInterface, {
let h = await xhr.get(socksVerificationInterface, {
"X-Key": authKey ? btoa(String.fromCharCode.apply(null, authKey)) : ""
});
@@ -262,7 +272,10 @@ function startApp(rootEl) {
key: h.getResponseHeader("X-Key"),
timeout: h.getResponseHeader("X-Timeout"),
heartbeat: h.getResponseHeader("X-Heartbeat"),
date: serverDate ? new Date(serverDate) : null
date: serverDate ? new Date(serverDate) : null,
data: h.responseText,
onlyAllowPresetRemotes:
h.getResponseHeader("X-OnlyAllowPresetRemotes") === "yes"
};
},
async submitAuth(passphrase) {
@@ -273,17 +286,12 @@ function startApp(rootEl) {
switch (result.result) {
case 200:
this.socket = this.buildSocket(
{
data: passphrase,
fetch() {
return this.data;
}
},
result.timeout,
result.heartbeat
);
this.page = "app";
this.executeHomeApp(result, {
data: passphrase,
fetch() {
return this.data;
}
});
break;
case 403:

View File

@@ -15,8 +15,9 @@
// 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 "../stream/subscribe.js";
import Exception from "./exception.js";
import * as presets from "./presets.js";
export const NEXT_PROMPT = 1;
export const NEXT_WAIT = 2;
@@ -131,8 +132,12 @@ const defField = {
type: "",
value: "",
example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(v) {
return "OK";
return "";
}
};
@@ -155,19 +160,21 @@ export function field(def, f) {
}
for (let i in f) {
if (typeof n[i] !== typeof f[i]) {
throw new Exception(
'Field data type for "' +
i +
'" was not unmatched. Expecting "' +
typeof def[i] +
'", got "' +
typeof f[i] +
'" instead'
);
if (typeof n[i] === typeof f[i]) {
n[i] = f[i];
continue;
}
n[i] = f[i];
throw new Exception(
'Field data type for "' +
i +
'" was unmatched. Expecting "' +
typeof n[i] +
'", got "' +
typeof f[i] +
'" instead'
);
}
if (!n["name"]) {
@@ -206,6 +213,31 @@ export function fields(definitions, fs) {
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
*
* @returns {object}
*
*/
export function fieldsWithPreset(definitions, fieldsData, presetData) {
let newFields = fields(definitions, fieldsData);
for (let i in newFields) {
try {
newFields[i].value = presetData.meta(newFields[i].name);
newFields[i].readonly = true;
} catch (e) {
// Do nothing
}
}
return newFields;
}
class Prompt {
/**
* constructor
@@ -457,7 +489,7 @@ class Wizard {
/**
* constructor
*
* @param {function} builder Command builder
* @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
@@ -468,6 +500,8 @@ class Wizard {
this.subs = subs;
this.done = done;
this.closed = false;
this.built.run();
}
/**
@@ -583,8 +617,14 @@ class Builder {
*/
constructor(command) {
this.cid = command.id();
this.builder = (n, i, r, u, y, x, l) => {
return command.builder(n, i, r, u, y, x, l);
this.represeter = n => {
return command.represet(n);
};
this.wizarder = (n, i, r, u, y, x, l) => {
return command.wizard(n, i, r, u, y, x, l);
};
this.executer = (n, i, r, u, y, x, l) => {
return command.execute(n, i, r, u, y, x, l);
};
this.launchCmd = (n, i, r, u, y, x) => {
return command.launch(n, i, r, u, y, x);
@@ -638,7 +678,38 @@ class Builder {
}
/**
* Build command wizard
* 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 {function} done Callback which will be called when wizard is done
*
* @returns {Wizard} Command wizard
*
*/
wizard(streams, controls, history, preset, session, done) {
let subs = new subscribe.Subscribe();
return new Wizard(
this.wizarder(
new Info(this),
preset,
session,
streams,
subs,
controls,
history
),
subs,
done
);
}
/**
* Execute an automatic command wizard
*
* @param {stream.Streams} streams
* @param {controls.Controls} controls
@@ -650,11 +721,11 @@ class Builder {
* @returns {Wizard} Command wizard
*
*/
build(streams, controls, history, config, session, done) {
execute(streams, controls, history, config, session, done) {
let subs = new subscribe.Subscribe();
return new Wizard(
this.builder(
this.executer(
new Info(this),
config,
session,
@@ -707,19 +778,44 @@ class Builder {
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 {[]object} commands Command array
* @param {Array<object>} commands Command array
*
*/
constructor(commands) {
this.commands = [];
for (let i in commands) {
for (let i = 0; i < commands.length; i++) {
this.commands.push(new Builder(commands[i]));
}
}
@@ -727,7 +823,7 @@ export class Commands {
/**
* Return all commands
*
* @returns {[]Builder} A group of command
* @returns {Array<Builder>} A group of command
*
*/
all() {
@@ -745,4 +841,28 @@ export class Commands {
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;
}
}

View File

@@ -17,6 +17,16 @@
import * as command from "./commands.js";
function metaContains(data, metaName, valContains) {
switch (typeof data[metaName]) {
case "string":
return data[metaName].indexOf(valContains) >= 0;
default:
return false;
}
}
export class History {
/**
* constructor
@@ -206,4 +216,39 @@ export class History {
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;
}
}

292
ui/commands/presets.js Normal file
View File

@@ -0,0 +1,292 @@
// Sshwifty - A Web SSH client
//
// Copyright (C) 2019-2020 Rui NI <nirui@gmx.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];
}
/**
* 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;
}
}
/**
* 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;
}
}

View File

@@ -15,17 +15,18 @@
// 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 event from "./events.js";
import * as reader from "../stream/reader.js";
import * as stream from "../stream/stream.js";
import * as controls from "./controls.js";
import * as header from "../stream/header.js";
import * as history from "./history.js";
import * as strings from "./string.js";
import * as event from "./events.js";
import Exception from "./exception.js";
import * as history from "./history.js";
import * as presets from "./presets.js";
import * as strings from "./string.js";
const AUTHMETHOD_NONE = 0x00;
const AUTHMETHOD_PASSPHRASE = 0x01;
@@ -57,6 +58,8 @@ const FingerprintPromptVerifyPassed = 0x00;
const FingerprintPromptVerifyNoRecord = 0x01;
const FingerprintPromptVerifyMismatch = 0x02;
const HostMaxSearchResults = 3;
class SSH {
/**
* constructor
@@ -245,6 +248,10 @@ const initialFieldDef = {
type: "text",
value: "",
example: "guest",
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
if (d.length <= 0) {
throw new Error("Username must be specified");
@@ -265,6 +272,10 @@ const initialFieldDef = {
type: "text",
value: "",
example: "ssh.vaguly.com:22",
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
if (d.length <= 0) {
throw new Error("Hostname must be specified");
@@ -295,6 +306,10 @@ const initialFieldDef = {
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) {
@@ -315,6 +330,10 @@ const initialFieldDef = {
"SSH session is handled by the backend. Traffic will be decrypted " +
"on the backend server and then transmit back to your client.",
example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
return "";
}
@@ -325,6 +344,10 @@ const initialFieldDef = {
type: "password",
value: "",
example: "----------",
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
if (d.length <= 0) {
throw new Error("Password must be specified");
@@ -356,6 +379,10 @@ const initialFieldDef = {
type: "textfile",
value: "",
example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
if (d.length <= 0) {
throw new Error("Private Key must be specified");
@@ -410,6 +437,10 @@ const initialFieldDef = {
type: "radio",
value: "",
example: "Password,Private Key,None",
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
switch (d) {
case "Password":
@@ -431,6 +462,10 @@ const initialFieldDef = {
type: "textdata",
value: "",
example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(d) {
return "";
}
@@ -468,7 +503,7 @@ class Wizard {
* constructor
*
* @param {command.Info} info
* @param {object} config
* @param {presets.Preset} preset
* @param {object} session
* @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs
@@ -476,11 +511,11 @@ class Wizard {
* @param {history.History} history
*
*/
constructor(info, config, session, streams, subs, controls, history) {
constructor(info, preset, session, streams, subs, controls, history) {
this.info = info;
this.preset = preset;
this.hasStarted = false;
this.streams = streams;
this.config = config;
this.session = session
? session
: {
@@ -489,7 +524,9 @@ class Wizard {
this.step = subs;
this.controls = controls.get("SSH");
this.history = history;
}
run() {
this.step.resolve(this.stepInitialPrompt());
}
@@ -680,26 +717,6 @@ class Wizard {
stepInitialPrompt() {
let self = this;
if (this.config) {
self.hasStarted = true;
self.streams.request(COMMAND_ID, sd => {
return self.buildCommand(
sd,
{
user: this.config.user,
authentication: this.config.authentication,
host: this.config.host,
charset: this.config.charset ? this.config.charset : "utf-8",
fingerprint: this.config.fingerprint
},
this.session
);
});
return self.stepWaitForAcceptWait();
}
return command.prompt(
"SSH",
"Secure Shell Host",
@@ -717,26 +734,57 @@ class Wizard {
charset: r.encoding,
fingerprint: ""
},
this.session
self.session
);
});
self.step.resolve(self.stepWaitForAcceptWait());
},
() => {},
command.fields(initialFieldDef, [
{ name: "User" },
{ name: "Host" },
{ name: "Authentication" },
{ name: "Encoding" },
{ name: "Notice" }
])
command.fieldsWithPreset(
initialFieldDef,
[
{ name: "User" },
{
name: "Host",
suggestions(input) {
const hosts = self.history.search(
"SSH",
"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: {
User: hosts[i].data.user,
Authentication: hosts[i].data.authentication,
Encoding: hosts[i].data.charset
}
});
}
return sugg;
}
},
{ name: "Authentication" },
{ name: "Encoding" },
{ name: "Notice" }
],
self.preset
)
);
}
async stepFingerprintPrompt(rd, sd, verify, newFingerprint) {
let self = this,
fingerprintData = new TextDecoder("utf-8").decode(
const self = this;
let fingerprintData = new TextDecoder("utf-8").decode(
await reader.readCompletely(rd)
),
fingerprintChanged = false;
@@ -745,7 +793,7 @@ class Wizard {
case FingerprintPromptVerifyPassed:
sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0]));
return this.stepContinueWaitForEstablishWait();
return self.stepContinueWaitForEstablishWait();
case FingerprintPromptVerifyMismatch:
fingerprintChanged = true;
@@ -783,8 +831,9 @@ class Wizard {
}
async stepCredentialPrompt(rd, sd, config, newCredential) {
let self = this,
fields = [];
const self = this;
let fields = [];
if (config.credential.length > 0) {
sd.send(
@@ -792,7 +841,7 @@ class Wizard {
new TextEncoder().encode(config.credential)
);
return this.stepContinueWaitForEstablishWait();
return self.stepContinueWaitForEstablishWait();
}
switch (config.auth) {
@@ -836,11 +885,61 @@ class Wizard {
)
);
},
command.fields(initialFieldDef, fields)
command.fieldsWithPreset(initialFieldDef, fields, self.preset)
);
}
}
class Executer extends Wizard {
/**
* constructor
*
* @param {command.Info} info
* @param {config} config
* @param {object} session
* @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs
* @param {controls.Controls} controls
* @param {history.History} history
*
*/
constructor(info, config, session, streams, subs, controls, history) {
super(
info,
presets.emptyPreset(),
session,
streams,
subs,
controls,
history
);
this.config = config;
}
stepInitialPrompt() {
const self = this;
self.hasStarted = true;
self.streams.request(COMMAND_ID, sd => {
return self.buildCommand(
sd,
{
user: self.config.user,
authentication: self.config.authentication,
host: self.config.host,
charset: self.config.charset ? self.config.charset : "utf-8",
fingerprint: self.config.fingerprint
},
self.session
);
});
return self.stepWaitForAcceptWait();
}
}
export class Command {
constructor() {}
@@ -860,8 +959,20 @@ export class Command {
return "#3c8";
}
builder(info, config, session, streams, subs, controls, history) {
return new Wizard(info, config, session, streams, subs, controls, history);
wizard(info, preset, session, streams, subs, controls, history) {
return new Wizard(info, preset, session, streams, subs, controls, history);
}
execute(info, config, session, streams, subs, controls, history) {
return new Executer(
info,
config,
session,
streams,
subs,
controls,
history
);
}
launch(info, launcher, streams, subs, controls, history) {
@@ -893,7 +1004,7 @@ export class Command {
);
}
return this.builder(
return this.execute(
info,
{
user: user,
@@ -920,4 +1031,14 @@ export class Command {
(config.charset ? config.charset : "utf-8")
);
}
represet(preset) {
const host = preset.host();
if (host.length > 0) {
preset.insertMeta("Host", host);
}
return preset;
}
}

View File

@@ -15,16 +15,17 @@
// 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 event from "./events.js";
import * as reader from "../stream/reader.js";
import * as stream from "../stream/stream.js";
import * as controls from "./controls.js";
import * as history from "./history.js";
import * as header from "../stream/header.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;
@@ -36,6 +37,8 @@ const SERVER_DIAL_CONNECTED = 0x02;
const DEFAULT_PORT = 23;
const HostMaxSearchResults = 3;
class Telnet {
/**
* constructor
@@ -185,6 +188,10 @@ const initialFieldDef = {
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");
@@ -215,6 +222,10 @@ const initialFieldDef = {
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) {
@@ -234,7 +245,7 @@ class Wizard {
* constructor
*
* @param {command.Info} info
* @param {object} config
* @param {presets.Preset} preset
* @param {object} session
* @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs
@@ -242,16 +253,18 @@ class Wizard {
* @param {history.History} history
*
*/
constructor(info, config, session, streams, subs, controls, history) {
constructor(info, preset, session, streams, subs, controls, history) {
this.info = info;
this.preset = preset;
this.hasStarted = false;
this.streams = streams;
this.config = config;
this.session = session;
this.step = subs;
this.controls = controls.get("Telnet");
this.history = history;
}
run() {
this.step.resolve(this.stepInitialPrompt());
}
@@ -378,24 +391,7 @@ class Wizard {
}
stepInitialPrompt() {
let self = this;
if (this.config) {
self.hasStarted = true;
self.streams.request(COMMAND_ID, sd => {
return self.buildCommand(
sd,
{
host: this.config.host,
charset: this.config.charset ? this.config.charset : "utf-8"
},
this.session
);
});
return self.stepWaitForAcceptWait();
}
const self = this;
return command.prompt(
"Telnet",
@@ -411,18 +407,96 @@ class Wizard {
host: r.host,
charset: r.encoding
},
this.session
self.session
);
});
self.step.resolve(self.stepWaitForAcceptWait());
},
() => {},
command.fields(initialFieldDef, [{ name: "Host" }, { name: "Encoding" }])
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
)
);
}
}
class Executor extends Wizard {
/**
* constructor
*
* @param {command.Info} info
* @param {object} config
* @param {object} session
* @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs
* @param {controls.Controls} controls
* @param {history.History} history
*
*/
constructor(info, config, session, streams, subs, controls, history) {
super(
info,
presets.emptyPreset(),
session,
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() {}
@@ -442,8 +516,20 @@ export class Command {
return "#6ac";
}
builder(info, config, session, streams, subs, controls, history) {
return new Wizard(info, config, session, streams, subs, controls, history);
wizard(info, preset, session, streams, subs, controls, history) {
return new Wizard(info, preset, session, streams, subs, controls, history);
}
execute(info, config, session, streams, subs, controls, history) {
return new Executor(
info,
config,
session,
streams,
subs,
controls,
history
);
}
launch(info, launcher, streams, subs, controls, history) {
@@ -476,7 +562,7 @@ export class Command {
}
}
return this.builder(
return this.execute(
info,
{
host: d[0],
@@ -493,4 +579,14 @@ export class Command {
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;
}
}

View File

@@ -216,6 +216,28 @@ body {
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;

View File

@@ -110,6 +110,8 @@
: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"
@@ -119,6 +121,7 @@
@connector-select="connectNew"
@known-select="connectKnown"
@known-remove="removeKnown"
@preset-select="connectPreset"
@known-clear-session="clearSessionKnown"
>
<connector
@@ -143,8 +146,9 @@
@current="switchTab"
@retap="retapTab"
@close="closeTab"
></tab-window></div
></template>
></tab-window>
</div>
</template>
<script>
import "./home.css";
@@ -159,6 +163,8 @@ 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: ";
@@ -198,6 +204,18 @@ export default {
default: () => {
return null;
}
},
presetData: {
type: Object,
default: () => {
return new presets.Presets([]);
}
},
restrictedToPresets: {
type: Boolean,
default: () => {
return false;
}
}
},
data() {
@@ -220,6 +238,7 @@ export default {
busy: false,
knowns: history.all()
},
presets: this.commands.mergePresets(this.presetData),
tab: {
current: -1,
lastID: 0,
@@ -317,22 +336,45 @@ export default {
);
},
connectNew(connector) {
this.runConnect(stream => {
this.connector.connector = {
const self = this;
self.runConnect(stream => {
self.connector.connector = {
id: connector.id(),
name: connector.name(),
description: connector.description(),
wizard: connector.build(
wizard: connector.wizard(
stream,
this.controls,
this.connector.historyRec,
null,
self.controls,
self.connector.historyRec,
presets.emptyPreset(),
null,
() => {}
)
};
this.connector.inputting = true;
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) {
@@ -349,27 +391,27 @@ export default {
return connector;
},
connectKnown(known) {
this.runConnect(stream => {
let connector = this.getConnectorByType(known.type);
const self = this;
self.runConnect(stream => {
let connector = self.getConnectorByType(known.type);
if (!connector) {
alert("Unknown connector: " + known.type);
this.connector.inputting = false;
self.connector.inputting = false;
return;
}
const self = this;
this.connector.connector = {
self.connector.connector = {
id: connector.id(),
name: connector.name(),
description: connector.description(),
wizard: connector.build(
wizard: connector.execute(
stream,
this.controls,
this.connector.historyRec,
self.controls,
self.connector.historyRec,
known.data,
known.session,
() => {
@@ -378,7 +420,7 @@ export default {
)
};
this.connector.inputting = true;
self.connector.inputting = true;
});
},
parseConnectLauncher(ll) {

View File

@@ -44,11 +44,14 @@
<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>
@@ -100,6 +103,14 @@ export default {
type: Boolean,
default: false
},
presets: {
type: Array,
default: () => []
},
restrictedToPresets: {
type: Boolean,
default: () => false
},
knowns: {
type: Array,
default: () => []
@@ -160,6 +171,13 @@ export default {
this.$emit("known-remove", uid);
},
selectPreset(preset) {
if (this.inputting) {
return;
}
this.$emit("preset-select", preset);
},
clearSessionKnown(uid) {
if (this.inputting) {
return;

View File

@@ -22,13 +22,20 @@
#connect-known-list {
min-height: 200px;
font-size: 0.75em;
padding: 15px;
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 {
}
@@ -62,10 +69,11 @@
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
#connect-known-list-import {
margin: 15px 0 10px 0;
margin: 15px 0;
color: #aaa;
font-size: 1.1em;
text-align: center;
@@ -76,26 +84,30 @@
text-decoration: none;
}
#connect-known-list li {
#connect-known-list-list {
padding: 15px 20px 20px 20px;
}
#connect-known-list-list li {
width: 50%;
position: relative;
}
@media (max-width: 480px) {
#connect-known-list li {
#connect-known-list-list li {
width: 100%;
}
}
#connect-known-list li .lst-wrap {
#connect-known-list-list li > .lst-wrap {
cursor: pointer;
}
#connect-known-list li .lst-wrap:hover {
#connect-known-list-list li > .lst-wrap:hover {
background: #444;
}
#connect-known-list li .labels {
#connect-known-list-list li > .labels {
position: absolute;
top: 0;
left: 0;
@@ -104,14 +116,14 @@
letter-spacing: 1px;
}
#connect-known-list li .labels > .type {
#connect-known-list-list li > .labels > .type {
display: inline-block;
padding: 3px;
background: #a56;
color: #fff;
}
#connect-known-list li .labels > .opt {
#connect-known-list-list li > .labels > .opt {
display: none;
padding: 3px;
background: #a56;
@@ -121,43 +133,44 @@
}
@media (max-width: 480px) {
#connect-known-list li .labels > .opt {
#connect-known-list-list li > .labels > .opt {
display: inline-block;
}
}
#connect-known-list li .labels > .opt.link {
#connect-known-list-list li > .labels > .opt.link {
background: #287;
color: #fff;
}
#connect-known-list li .labels > .opt.link:after {
#connect-known-list-list li > .labels > .opt.link:after {
content: "\02936";
}
#connect-known-list li .labels > .opt.del {
#connect-known-list-list li > .labels > .opt.del {
background: #a56;
color: #fff;
}
#connect-known-list li .labels > .opt.clr {
#connect-known-list-list li > .labels > .opt.clr {
background: #b71;
color: #fff;
}
#connect-known-list li:hover .labels > .opt,
#connect-known-list li:focus .labels > .opt {
#connect-known-list-list li:hover > .labels > .opt,
#connect-known-list-list li:focus > .labels > .opt {
display: inline-block;
}
#connect-known-list li h2 {
#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 li h2::before {
#connect-known-list-list li > .lst-wrap > h4::before {
content: ">_";
color: #555;
font-size: 0.8em;
@@ -167,7 +180,70 @@
border-radius: 2px;
}
#connect-known-list li h2.highlight::before {
#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: 480px) {
#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;
}

View File

@@ -19,53 +19,104 @@
<template>
<div id="connect-known-list" :class="{ reloaded: reloaded }">
<div v-if="knownList.length <= 0" id="connect-known-list-empty">
<div
v-if="knownList.length <= 0 && presets <= 0"
id="connect-known-list-empty"
>
No known remote available
</div>
<ul v-else id="connect-known-list-list" 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>
<div v-else>
<div v-if="knownList.length > 0" id="connect-known-list-list">
<h3>Connected before</h3>
<a
class="opt link"
href="javascript:;"
@click="launcher(known, $event)"
>
{{ known.copyStatus }}
</a>
<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
v-if="!known.data.session"
class="opt del"
href="javascript:;"
@click="remove(known.data.uid)"
<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) }"
>
Remove
</a>
<a
v-else
class="opt clr"
href="javascript:;"
title="Clear session data"
@click="clearSession(known.data.uid)"
>
Clear
</a>
<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 disabled outgoing access to all remote hosts except
those been defined as preset.
</div>
<div class="lst-wrap" @click="select(known.data)">
<h2
:title="known.data.title"
:class="{ highlight: known.data.session }"
>
{{ known.data.title }}
</h2>
Last: {{ known.data.last.toLocaleString() }}
</div>
</li>
</ul>
</div>
</div>
<div id="connect-known-list-import">
Tip: You can
@@ -81,6 +132,14 @@ import "./connect_known.css";
export default {
props: {
presets: {
type: Array,
default: () => []
},
restrictedToPresets: {
type: Boolean,
default: () => false
},
knowns: {
type: Array,
default: () => []
@@ -147,6 +206,20 @@ export default {
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;

View File

@@ -15,7 +15,7 @@
// 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 function head(url, headers) {
export function get(url, headers) {
return new Promise((res, rej) => {
let authReq = new XMLHttpRequest();
@@ -35,7 +35,7 @@ export function head(url, headers) {
rej(e);
};
authReq.open("HEAD", url, true);
authReq.open("GET", url, true);
for (let h in headers) {
authReq.setRequestHeader(h, headers[h]);