From 67c99e3092abbae009b711ce5d4ea64d9cfebd00 Mon Sep 17 00:00:00 2001 From: NI Date: Fri, 7 Feb 2020 18:05:44 +0800 Subject: [PATCH] Implemented the host name auto suggestion, and added Preset feature --- Dockerfile | 4 +- README.md | 86 +++++- application/configuration/config.go | 50 +++- application/configuration/loader_enviro.go | 16 ++ application/configuration/loader_file.go | 87 ++++-- application/controller/controller.go | 8 +- application/controller/socket.go | 48 ---- application/controller/socket_verify.go | 138 ++++++++++ application/network/dial_ac.go | 61 +++++ sshwifty.conf.example.json | 33 ++- ui/app.js | 116 ++++---- ui/commands/commands.js | 164 ++++++++++-- ui/commands/history.js | 45 ++++ ui/commands/presets.js | 292 +++++++++++++++++++++ ui/commands/ssh.js | 215 +++++++++++---- ui/commands/telnet.js | 158 ++++++++--- ui/common.css | 22 ++ ui/home.vue | 80 ++++-- ui/widgets/connect.vue | 18 ++ ui/widgets/connect_known.css | 114 ++++++-- ui/widgets/connect_known.vue | 155 ++++++++--- ui/xhr.js | 4 +- 22 files changed, 1582 insertions(+), 332 deletions(-) create mode 100644 application/controller/socket_verify.go create mode 100644 application/network/dial_ac.go create mode 100644 ui/commands/presets.js diff --git a/Dockerfile b/Dockerfile index 68548cf..d7181bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/README.md b/README.md index 104527c..15a0367 100644 --- a/README.md +++ b/README.md @@ -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,14 +336,14 @@ 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!) Appreciate your help! -Enjoy! \ No newline at end of file +Enjoy! diff --git a/application/configuration/config.go b/application/configuration/config.go index 1a77129..8d4c0aa 100644 --- a/application/configuration/config.go +++ b/application/configuration/config.go @@ -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, } } diff --git a/application/configuration/loader_enviro.go b/application/configuration/loader_enviro.go index 69a1efa..93cf4de 100644 --- a/application/configuration/loader_enviro.go +++ b/application/configuration/loader_enviro.go @@ -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 } } diff --git a/application/configuration/loader_file.go b/application/configuration/loader_file.go index 89e711f..4d14856 100644 --- a/application/configuration/loader_file.go +++ b/application/configuration/loader_file.go @@ -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 } diff --git a/application/controller/controller.go b/application/controller/controller.go index cae1dc5..a4fd07e 100644 --- a/application/controller/controller.go +++ b/application/controller/controller.go @@ -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), } } } diff --git a/application/controller/socket.go b/application/controller/socket.go index 1d1c8a9..e6b48ed 100644 --- a/application/controller/socket.go +++ b/application/controller/socket.go @@ -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 { diff --git a/application/controller/socket_verify.go b/application/controller/socket_verify.go new file mode 100644 index 0000000..ff8b884 --- /dev/null +++ b/application/controller/socket_verify.go @@ -0,0 +1,138 @@ +// Sshwifty - A Web SSH client +// +// Copyright (C) 2019-2020 Rui NI +// +// 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 . + +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 +} diff --git a/application/network/dial_ac.go b/application/network/dial_ac.go new file mode 100644 index 0000000..b55411b --- /dev/null +++ b/application/network/dial_ac.go @@ -0,0 +1,61 @@ +// Sshwifty - A Web SSH client +// +// Copyright (C) 2019-2020 Rui NI +// +// 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 . + +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) + } +} diff --git a/sshwifty.conf.example.json b/sshwifty.conf.example.json index b92f2ca..8cacf8e 100644 --- a/sshwifty.conf.example.json +++ b/sshwifty.conf.example.json @@ -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 } diff --git a/ui/app.js b/ui/app.js index 8cb0790..84edd57 100644 --- a/ui/app.js +++ b/ui/app.js @@ -15,28 +15,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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: diff --git a/ui/commands/commands.js b/ui/commands/commands.js index 34a77b2..96476bf 100644 --- a/ui/commands/commands.js +++ b/ui/commands/commands.js @@ -15,8 +15,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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} 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} 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} + * + */ + 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; + } } diff --git a/ui/commands/history.js b/ui/commands/history.js index 92c1f66..be135b0 100644 --- a/ui/commands/history.js +++ b/ui/commands/history.js @@ -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; + } } diff --git a/ui/commands/presets.js b/ui/commands/presets.js new file mode 100644 index 0000000..9a66751 --- /dev/null +++ b/ui/commands/presets.js @@ -0,0 +1,292 @@ +// Sshwifty - A Web SSH client +// +// Copyright (C) 2019-2020 Rui NI +// +// 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 . + +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} 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} + * + */ + 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} + * + */ + 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} + * + */ + 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; + } +} diff --git a/ui/commands/ssh.js b/ui/commands/ssh.js index be02e7f..986d735 100644 --- a/ui/commands/ssh.js +++ b/ui/commands/ssh.js @@ -15,17 +15,18 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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; + } } diff --git a/ui/commands/telnet.js b/ui/commands/telnet.js index 027c06b..5ebd2d4 100644 --- a/ui/commands/telnet.js +++ b/ui/commands/telnet.js @@ -15,16 +15,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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; + } } diff --git a/ui/common.css b/ui/common.css index 98a96c9..072e50d 100644 --- a/ui/common.css +++ b/ui/common.css @@ -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; diff --git a/ui/home.vue b/ui/home.vue index deee886..a72481e 100644 --- a/ui/home.vue +++ b/ui/home.vue @@ -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" > + > + +