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_TLSCERTIFICATEFILE= \
SSHWIFTY_TLSCERTIFICATEKEYFILE= \ SSHWIFTY_TLSCERTIFICATEKEYFILE= \
SSHWIFTY_DOCKER_TLSCERT= \ SSHWIFTY_DOCKER_TLSCERT= \
SSHWIFTY_DOCKER_TLSCERTKEY= SSHWIFTY_DOCKER_TLSCERTKEY= \
SSHWIFTY_PRESETS= \
SSHWIFTY_ONLYALLOWPRESETREMOTES=
COPY --from=builder /sshwifty / COPY --from=builder /sshwifty /
COPY . /sshwifty-src COPY . /sshwifty-src
RUN set -ex && \ 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 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) ### 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. working directory.
Notice: `Dockerfile` contains the entire build procedure of this software. 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 ### 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 default, the configuration loader will try to load file from default paths
first, when failed, environment variables will be used. first, when failed, environment variables will be used.
You can also specify your own configuration file by setting `SSHWIFTY_CONFIG` You can also specify your own configuration file by setting up `SSHWIFTY_CONFIG`
environment variable. For example: environment variable before start the software. For example:
``` ```
$ SSHWIFTY_CONFIG=./sshwifty.conf.json ./sshwifty $ SSHWIFTY_CONFIG=./sshwifty.conf.json ./sshwifty
@@ -188,7 +189,66 @@ Here is all the options of a configuration file:
"InitialTimeout": 3, "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_LISTENINTERFACE
SSHWIFTY_TLSCERTIFICATEFILE SSHWIFTY_TLSCERTIFICATEFILE
SSHWIFTY_TLSCERTIFICATEKEYFILE SSHWIFTY_TLSCERTIFICATEKEYFILE
SSHWIFTY_PRESETS
SSHWIFTY_ONLYALLOWPRESETREMOTES
``` ```
The option they represented is corresponded to their counterparts in the 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 Third-party components used by this project are licensed under their respective
licenses. See [DEPENDENCIES.md] for dependencies used by this project. licenses. See [DEPENDENCIES.md] for dependencies used by this project.
[LICENSE.md]: LICENSE.md [license.md]: LICENSE.md
[DEPENDENCIES.md]: DEPENDENCIES.md [dependencies.md]: DEPENDENCIES.md
## Contribute ## Contribute
@@ -274,10 +336,10 @@ Sorry.
Upon release (Which is then you're able to read this file), this project will 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. 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 Please do not send pull request. If you need new feature, fork it, add it by
it like one of your own project. yourself, and maintain it like one of your own project.
(Notice: Typo, grammar error or invalid use of language in the source code and (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!) document is categorized as bug, please report them if you found any. Thank you!)

View File

@@ -120,6 +120,14 @@ func (s Server) Verify() error {
return nil 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 // Configuration contains configuration of the application
type Configuration struct { type Configuration struct {
HostName string HostName string
@@ -127,6 +135,8 @@ type Configuration struct {
Dialer network.Dial Dialer network.Dial
DialTimeout time.Duration DialTimeout time.Duration
Servers []Server Servers []Server
Presets []Preset
OnlyAllowPresetRemotes bool
} }
// Common settings shared by mulitple servers // Common settings shared by mulitple servers
@@ -135,6 +145,8 @@ type Common struct {
SharedKey string SharedKey string
Dialer network.Dial Dialer network.Dial
DialTimeout time.Duration DialTimeout time.Duration
Presets []Preset
OnlyAllowPresetRemotes bool
} }
// Verify verifies current setting // Verify verifies current setting
@@ -164,6 +176,16 @@ func (c Configuration) Common() Common {
dialer = network.TCPDial() 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 dialTimeout := c.DialTimeout
if dialTimeout <= 1*time.Second { if dialTimeout <= 1*time.Second {
@@ -173,8 +195,10 @@ func (c Configuration) Common() Common {
return Common{ return Common{
HostName: c.HostName, HostName: c.HostName,
SharedKey: c.SharedKey, SharedKey: c.SharedKey,
Dialer: c.Dialer, Dialer: dialer,
DialTimeout: c.DialTimeout, DialTimeout: c.DialTimeout,
Presets: c.Presets,
OnlyAllowPresetRemotes: c.OnlyAllowPresetRemotes,
} }
} }

View File

@@ -18,6 +18,7 @@
package configuration package configuration
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
@@ -102,12 +103,27 @@ func Enviro() Loader {
TLSCertificateKeyFile: parseEviro("SSHWIFTY_TLSCERTIFICATEKEYFILE"), 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{ return enviroTypeName, Configuration{
HostName: cfg.HostName, HostName: cfg.HostName,
SharedKey: cfg.SharedKey, SharedKey: cfg.SharedKey,
Dialer: dialer, Dialer: dialer,
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
Servers: []Server{cfgSer.build()}, Servers: []Server{cfgSer.build()},
Presets: presets,
OnlyAllowPresetRemotes: len(
parseEviro("SSHWIFTY_ONLYALLOWPRESETREMOTES")) > 0,
}, nil }, nil
} }
} }

View File

@@ -27,9 +27,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/niruix/sshwifty/application/network"
"github.com/niruix/sshwifty/application/log" "github.com/niruix/sshwifty/application/log"
"github.com/niruix/sshwifty/application/network"
) )
const ( 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 { type fileCfgCommon struct {
HostName string // Host name // Host name
SharedKey string // Shared key, empty to enable public access HostName string
DialTimeout int // DialTimeout, min 5s
Socks5 string // Socks5 server address, optional // Shared key, empty to enable public access
Socks5User string // Login user for socks5 server, optional SharedKey string
Socks5Password string // Login pass for socks5 server, optional
Servers []*fileCfgServer // Servers // 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) { func (f fileCfgCommon) build() (fileCfgCommon, network.Dial, error) {
@@ -118,6 +152,8 @@ func (f fileCfgCommon) build() (fileCfgCommon, network.Dial, error) {
Socks5User: f.Socks5User, Socks5User: f.Socks5User,
Socks5Password: f.Socks5Password, Socks5Password: f.Socks5Password,
Servers: f.Servers, Servers: f.Servers,
Presets: f.Presets,
OnlyAllowPresetRemotes: f.OnlyAllowPresetRemotes,
}, dialer, nil }, dialer, nil
} }
@@ -151,12 +187,21 @@ func loadFile(filePath string) (string, Configuration, error) {
servers[i] = finalCfg.Servers[i].build() 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{ return fileTypeName, Configuration{
HostName: finalCfg.HostName, HostName: finalCfg.HostName,
SharedKey: finalCfg.SharedKey, SharedKey: finalCfg.SharedKey,
Dialer: dialer, Dialer: dialer,
DialTimeout: time.Duration(finalCfg.DialTimeout) * time.Second, DialTimeout: time.Duration(finalCfg.DialTimeout) *
time.Second,
Servers: servers, Servers: servers,
Presets: presets,
OnlyAllowPresetRemotes: cfg.OnlyAllowPresetRemotes,
}, nil }, nil
} }

View File

@@ -46,6 +46,7 @@ type handler struct {
logger log.Logger logger log.Logger
homeCtl home homeCtl home
socketCtl socket socketCtl socket
socketVerifyCtl socketVerification
} }
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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": case "/sshwifty/socket":
err = serveController(h.socketCtl, w, r, clientLogger) err = serveController(h.socketCtl, w, r, clientLogger)
case "/sshwifty/socket/verify":
err = serveController(h.socketVerifyCtl, w, r, clientLogger)
case "/robots.txt": case "/robots.txt":
err = serveStaticCacheData( err = serveStaticCacheData(
@@ -155,12 +158,15 @@ func Builder(cmds command.Commands) server.HandlerBuilder {
cfg configuration.Server, cfg configuration.Server,
logger log.Logger, logger log.Logger,
) http.Handler { ) http.Handler {
socketCtl := newSocketCtl(commonCfg, cfg, cmds)
return handler{ return handler{
hostNameChecker: commonCfg.HostName + ":", hostNameChecker: commonCfg.HostName + ":",
commonCfg: commonCfg, commonCfg: commonCfg,
logger: logger, logger: logger,
homeCtl: home{}, 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 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 { func (s socket) buildWSFetcher(c *websocket.Conn) rw.FetchReaderFetcher {
return func() ([]byte, error) { return func() ([]byte, error) {
for { 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": "", "TLSCertificateFile": "",
"TLSCertificateKeyFile": "" "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
} }

View File

@@ -15,28 +15,24 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 Vue from "vue";
import Home from "./home.vue"; import "./app.css";
import Auth from "./auth.vue"; 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 { 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 ssh from "./commands/ssh.js";
import * as telnet from "./commands/telnet.js"; import * as telnet from "./commands/telnet.js";
import "./common.css";
import { Controls } from "./commands/controls.js";
import { Color as ControlColor } from "./commands/color.js";
import * as telnetctl from "./control/telnet.js";
import * as sshctl from "./control/ssh.js"; import * as sshctl from "./control/ssh.js";
import * as telnetctl from "./control/telnet.js";
import * as xhr from "./xhr.js";
import * as cipher from "./crypto.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; const backendQueryRetryDelay = 2000;
@@ -52,6 +48,8 @@ const mainTemplate = `
:connection="socket" :connection="socket"
:controls="controls" :controls="controls"
:commands="commands" :commands="commands"
:preset-data="presetData.presets"
:restricted-to-presets="presetData.restricted"
@navigate-to="changeURLHash" @navigate-to="changeURLHash"
@tab-opened="tabOpened" @tab-opened="tabOpened"
@tab-closed="tabClosed" @tab-closed="tabClosed"
@@ -66,6 +64,7 @@ const mainTemplate = `
`.trim(); `.trim();
const socksInterface = "/sshwifty/socket"; const socksInterface = "/sshwifty/socket";
const socksVerificationInterface = socksInterface + "/verify";
function startApp(rootEl) { function startApp(rootEl) {
const pageTitle = document.title; const pageTitle = document.title;
@@ -93,6 +92,10 @@ function startApp(rootEl) {
: "", : "",
page: "loading", page: "loading",
key: "", key: "",
presetData: {
presets: new Presets([]),
restricted: false
},
authErr: "", authErr: "",
loadErr: "", loadErr: "",
socket: null, socket: null,
@@ -163,6 +166,18 @@ function startApp(rootEl) {
heartbeatInterval * 1000 heartbeatInterval * 1000
); );
}, },
executeHomeApp(authResult, key) {
this.presetData = {
presets: new Presets(JSON.parse(authResult.data)),
restricted: authResult.onlyAllowPresetRemotes
};
this.socket = this.buildSocket(
key,
authResult.timeout,
authResult.heartbeat
);
this.page = "app";
},
async tryInitialAuth() { async tryInitialAuth() {
try { try {
let result = await this.doAuth(""); let result = await this.doAuth("");
@@ -188,8 +203,7 @@ function startApp(rootEl) {
switch (result.result) { switch (result.result) {
case 200: case 200:
this.socket = this.buildSocket( this.executeHomeApp(result, {
{
data: result.key, data: result.key,
async fetch() { async fetch() {
if (this.data) { if (this.data) {
@@ -212,11 +226,7 @@ function startApp(rootEl) {
return result.key; return result.key;
} }
}, });
result.timeout,
result.heartbeat
);
this.page = "app";
break; break;
case 403: case 403:
@@ -251,7 +261,7 @@ function startApp(rootEl) {
? null ? null
: await this.getSocketAuthKey(privateKey, this.key); : 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)) : "" "X-Key": authKey ? btoa(String.fromCharCode.apply(null, authKey)) : ""
}); });
@@ -262,7 +272,10 @@ function startApp(rootEl) {
key: h.getResponseHeader("X-Key"), key: h.getResponseHeader("X-Key"),
timeout: h.getResponseHeader("X-Timeout"), timeout: h.getResponseHeader("X-Timeout"),
heartbeat: h.getResponseHeader("X-Heartbeat"), 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) { async submitAuth(passphrase) {
@@ -273,17 +286,12 @@ function startApp(rootEl) {
switch (result.result) { switch (result.result) {
case 200: case 200:
this.socket = this.buildSocket( this.executeHomeApp(result, {
{
data: passphrase, data: passphrase,
fetch() { fetch() {
return this.data; return this.data;
} }
}, });
result.timeout,
result.heartbeat
);
this.page = "app";
break; break;
case 403: case 403:

View File

@@ -15,8 +15,9 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 * 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_PROMPT = 1;
export const NEXT_WAIT = 2; export const NEXT_WAIT = 2;
@@ -131,8 +132,12 @@ const defField = {
type: "", type: "",
value: "", value: "",
example: "", example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(v) { verify(v) {
return "OK"; return "";
} }
}; };
@@ -155,21 +160,23 @@ export function field(def, f) {
} }
for (let i in f) { for (let i in f) {
if (typeof n[i] !== typeof f[i]) { if (typeof n[i] === typeof f[i]) {
n[i] = f[i];
continue;
}
throw new Exception( throw new Exception(
'Field data type for "' + 'Field data type for "' +
i + i +
'" was not unmatched. Expecting "' + '" was unmatched. Expecting "' +
typeof def[i] + typeof n[i] +
'", got "' + '", got "' +
typeof f[i] + typeof f[i] +
'" instead' '" instead'
); );
} }
n[i] = f[i];
}
if (!n["name"]) { if (!n["name"]) {
throw new Exception('Field "name" must be specified'); throw new Exception('Field "name" must be specified');
} }
@@ -206,6 +213,31 @@ export function fields(definitions, fs) {
return fss; 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 { class Prompt {
/** /**
* constructor * constructor
@@ -457,7 +489,7 @@ class Wizard {
/** /**
* constructor * constructor
* *
* @param {function} builder Command builder * @param {object} built Command executer
* @param {subscribe.Subscribe} subs Wizard step subscriber * @param {subscribe.Subscribe} subs Wizard step subscriber
* @param {function} done Callback which will be called when the wizard * @param {function} done Callback which will be called when the wizard
* is done * is done
@@ -468,6 +500,8 @@ class Wizard {
this.subs = subs; this.subs = subs;
this.done = done; this.done = done;
this.closed = false; this.closed = false;
this.built.run();
} }
/** /**
@@ -583,8 +617,14 @@ class Builder {
*/ */
constructor(command) { constructor(command) {
this.cid = command.id(); this.cid = command.id();
this.builder = (n, i, r, u, y, x, l) => { this.represeter = n => {
return command.builder(n, i, r, u, y, x, l); 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) => { this.launchCmd = (n, i, r, u, y, x) => {
return command.launch(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 {stream.Streams} streams
* @param {controls.Controls} controls * @param {controls.Controls} controls
@@ -650,11 +721,11 @@ class Builder {
* @returns {Wizard} Command wizard * @returns {Wizard} Command wizard
* *
*/ */
build(streams, controls, history, config, session, done) { execute(streams, controls, history, config, session, done) {
let subs = new subscribe.Subscribe(); let subs = new subscribe.Subscribe();
return new Wizard( return new Wizard(
this.builder( this.executer(
new Info(this), new Info(this),
config, config,
session, session,
@@ -707,19 +778,44 @@ class Builder {
launcher(config) { launcher(config) {
return this.name() + ":" + encodeURI(this.launcherCmd(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 { export class Commands {
/** /**
* constructor * constructor
* *
* @param {[]object} commands Command array * @param {Array<object>} commands Command array
* *
*/ */
constructor(commands) { constructor(commands) {
this.commands = []; this.commands = [];
for (let i in commands) { for (let i = 0; i < commands.length; i++) {
this.commands.push(new Builder(commands[i])); this.commands.push(new Builder(commands[i]));
} }
} }
@@ -727,7 +823,7 @@ export class Commands {
/** /**
* Return all commands * Return all commands
* *
* @returns {[]Builder} A group of command * @returns {Array<Builder>} A group of command
* *
*/ */
all() { all() {
@@ -745,4 +841,28 @@ export class Commands {
select(id) { select(id) {
return this.commands[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"; 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 { export class History {
/** /**
* constructor * constructor
@@ -206,4 +216,39 @@ export class History {
this.store(); 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 // 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/>. // 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 address from "./address.js";
import * as command from "./commands.js"; import * as command from "./commands.js";
import * as common from "./common.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 controls from "./controls.js";
import * as header from "../stream/header.js"; import * as event from "./events.js";
import * as history from "./history.js";
import * as strings from "./string.js";
import Exception from "./exception.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_NONE = 0x00;
const AUTHMETHOD_PASSPHRASE = 0x01; const AUTHMETHOD_PASSPHRASE = 0x01;
@@ -57,6 +58,8 @@ const FingerprintPromptVerifyPassed = 0x00;
const FingerprintPromptVerifyNoRecord = 0x01; const FingerprintPromptVerifyNoRecord = 0x01;
const FingerprintPromptVerifyMismatch = 0x02; const FingerprintPromptVerifyMismatch = 0x02;
const HostMaxSearchResults = 3;
class SSH { class SSH {
/** /**
* constructor * constructor
@@ -245,6 +248,10 @@ const initialFieldDef = {
type: "text", type: "text",
value: "", value: "",
example: "guest", example: "guest",
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
if (d.length <= 0) { if (d.length <= 0) {
throw new Error("Username must be specified"); throw new Error("Username must be specified");
@@ -265,6 +272,10 @@ const initialFieldDef = {
type: "text", type: "text",
value: "", value: "",
example: "ssh.vaguly.com:22", example: "ssh.vaguly.com:22",
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
if (d.length <= 0) { if (d.length <= 0) {
throw new Error("Hostname must be specified"); throw new Error("Hostname must be specified");
@@ -295,6 +306,10 @@ const initialFieldDef = {
type: "select", type: "select",
value: "utf-8", value: "utf-8",
example: common.charsetPresets.join(","), example: common.charsetPresets.join(","),
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
for (let i in common.charsetPresets) { for (let i in common.charsetPresets) {
if (common.charsetPresets[i] !== d) { if (common.charsetPresets[i] !== d) {
@@ -315,6 +330,10 @@ const initialFieldDef = {
"SSH session is handled by the backend. Traffic will be decrypted " + "SSH session is handled by the backend. Traffic will be decrypted " +
"on the backend server and then transmit back to your client.", "on the backend server and then transmit back to your client.",
example: "", example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
return ""; return "";
} }
@@ -325,6 +344,10 @@ const initialFieldDef = {
type: "password", type: "password",
value: "", value: "",
example: "----------", example: "----------",
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
if (d.length <= 0) { if (d.length <= 0) {
throw new Error("Password must be specified"); throw new Error("Password must be specified");
@@ -356,6 +379,10 @@ const initialFieldDef = {
type: "textfile", type: "textfile",
value: "", value: "",
example: "", example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
if (d.length <= 0) { if (d.length <= 0) {
throw new Error("Private Key must be specified"); throw new Error("Private Key must be specified");
@@ -410,6 +437,10 @@ const initialFieldDef = {
type: "radio", type: "radio",
value: "", value: "",
example: "Password,Private Key,None", example: "Password,Private Key,None",
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
switch (d) { switch (d) {
case "Password": case "Password":
@@ -431,6 +462,10 @@ const initialFieldDef = {
type: "textdata", type: "textdata",
value: "", value: "",
example: "", example: "",
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
return ""; return "";
} }
@@ -468,7 +503,7 @@ class Wizard {
* constructor * constructor
* *
* @param {command.Info} info * @param {command.Info} info
* @param {object} config * @param {presets.Preset} preset
* @param {object} session * @param {object} session
* @param {streams.Streams} streams * @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs * @param {subscribe.Subscribe} subs
@@ -476,11 +511,11 @@ class Wizard {
* @param {history.History} history * @param {history.History} history
* *
*/ */
constructor(info, config, session, streams, subs, controls, history) { constructor(info, preset, session, streams, subs, controls, history) {
this.info = info; this.info = info;
this.preset = preset;
this.hasStarted = false; this.hasStarted = false;
this.streams = streams; this.streams = streams;
this.config = config;
this.session = session this.session = session
? session ? session
: { : {
@@ -489,7 +524,9 @@ class Wizard {
this.step = subs; this.step = subs;
this.controls = controls.get("SSH"); this.controls = controls.get("SSH");
this.history = history; this.history = history;
}
run() {
this.step.resolve(this.stepInitialPrompt()); this.step.resolve(this.stepInitialPrompt());
} }
@@ -680,26 +717,6 @@ class Wizard {
stepInitialPrompt() { stepInitialPrompt() {
let self = this; 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( return command.prompt(
"SSH", "SSH",
"Secure Shell Host", "Secure Shell Host",
@@ -717,26 +734,57 @@ class Wizard {
charset: r.encoding, charset: r.encoding,
fingerprint: "" fingerprint: ""
}, },
this.session self.session
); );
}); });
self.step.resolve(self.stepWaitForAcceptWait()); self.step.resolve(self.stepWaitForAcceptWait());
}, },
() => {}, () => {},
command.fields(initialFieldDef, [ command.fieldsWithPreset(
initialFieldDef,
[
{ name: "User" }, { name: "User" },
{ name: "Host" }, {
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: "Authentication" },
{ name: "Encoding" }, { name: "Encoding" },
{ name: "Notice" } { name: "Notice" }
]) ],
self.preset
)
); );
} }
async stepFingerprintPrompt(rd, sd, verify, newFingerprint) { async stepFingerprintPrompt(rd, sd, verify, newFingerprint) {
let self = this, const self = this;
fingerprintData = new TextDecoder("utf-8").decode(
let fingerprintData = new TextDecoder("utf-8").decode(
await reader.readCompletely(rd) await reader.readCompletely(rd)
), ),
fingerprintChanged = false; fingerprintChanged = false;
@@ -745,7 +793,7 @@ class Wizard {
case FingerprintPromptVerifyPassed: case FingerprintPromptVerifyPassed:
sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0])); sd.send(CLIENT_CONNECT_RESPOND_FINGERPRINT, new Uint8Array([0]));
return this.stepContinueWaitForEstablishWait(); return self.stepContinueWaitForEstablishWait();
case FingerprintPromptVerifyMismatch: case FingerprintPromptVerifyMismatch:
fingerprintChanged = true; fingerprintChanged = true;
@@ -783,8 +831,9 @@ class Wizard {
} }
async stepCredentialPrompt(rd, sd, config, newCredential) { async stepCredentialPrompt(rd, sd, config, newCredential) {
let self = this, const self = this;
fields = [];
let fields = [];
if (config.credential.length > 0) { if (config.credential.length > 0) {
sd.send( sd.send(
@@ -792,7 +841,7 @@ class Wizard {
new TextEncoder().encode(config.credential) new TextEncoder().encode(config.credential)
); );
return this.stepContinueWaitForEstablishWait(); return self.stepContinueWaitForEstablishWait();
} }
switch (config.auth) { 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 { export class Command {
constructor() {} constructor() {}
@@ -860,8 +959,20 @@ export class Command {
return "#3c8"; return "#3c8";
} }
builder(info, config, session, streams, subs, controls, history) { wizard(info, preset, session, streams, subs, controls, history) {
return new Wizard(info, config, 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) { launch(info, launcher, streams, subs, controls, history) {
@@ -893,7 +1004,7 @@ export class Command {
); );
} }
return this.builder( return this.execute(
info, info,
{ {
user: user, user: user,
@@ -920,4 +1031,14 @@ export class Command {
(config.charset ? config.charset : "utf-8") (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 // 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/>. // 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 address from "./address.js";
import * as command from "./commands.js"; import * as command from "./commands.js";
import * as common from "./common.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 controls from "./controls.js";
import * as history from "./history.js"; import * as event from "./events.js";
import * as header from "../stream/header.js";
import Exception from "./exception.js"; import Exception from "./exception.js";
import * as history from "./history.js";
import * as presets from "./presets.js";
const COMMAND_ID = 0x00; const COMMAND_ID = 0x00;
@@ -36,6 +37,8 @@ const SERVER_DIAL_CONNECTED = 0x02;
const DEFAULT_PORT = 23; const DEFAULT_PORT = 23;
const HostMaxSearchResults = 3;
class Telnet { class Telnet {
/** /**
* constructor * constructor
@@ -185,6 +188,10 @@ const initialFieldDef = {
type: "text", type: "text",
value: "", value: "",
example: "telnet.vaguly.com:23", example: "telnet.vaguly.com:23",
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
if (d.length <= 0) { if (d.length <= 0) {
throw new Error("Hostname must be specified"); throw new Error("Hostname must be specified");
@@ -215,6 +222,10 @@ const initialFieldDef = {
type: "select", type: "select",
value: "utf-8", value: "utf-8",
example: common.charsetPresets.join(","), example: common.charsetPresets.join(","),
readonly: false,
suggestions(input) {
return [];
},
verify(d) { verify(d) {
for (let i in common.charsetPresets) { for (let i in common.charsetPresets) {
if (common.charsetPresets[i] !== d) { if (common.charsetPresets[i] !== d) {
@@ -234,7 +245,7 @@ class Wizard {
* constructor * constructor
* *
* @param {command.Info} info * @param {command.Info} info
* @param {object} config * @param {presets.Preset} preset
* @param {object} session * @param {object} session
* @param {streams.Streams} streams * @param {streams.Streams} streams
* @param {subscribe.Subscribe} subs * @param {subscribe.Subscribe} subs
@@ -242,16 +253,18 @@ class Wizard {
* @param {history.History} history * @param {history.History} history
* *
*/ */
constructor(info, config, session, streams, subs, controls, history) { constructor(info, preset, session, streams, subs, controls, history) {
this.info = info; this.info = info;
this.preset = preset;
this.hasStarted = false; this.hasStarted = false;
this.streams = streams; this.streams = streams;
this.config = config;
this.session = session; this.session = session;
this.step = subs; this.step = subs;
this.controls = controls.get("Telnet"); this.controls = controls.get("Telnet");
this.history = history; this.history = history;
}
run() {
this.step.resolve(this.stepInitialPrompt()); this.step.resolve(this.stepInitialPrompt());
} }
@@ -378,24 +391,7 @@ class Wizard {
} }
stepInitialPrompt() { stepInitialPrompt() {
let self = this; const 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();
}
return command.prompt( return command.prompt(
"Telnet", "Telnet",
@@ -411,15 +407,93 @@ class Wizard {
host: r.host, host: r.host,
charset: r.encoding charset: r.encoding
}, },
this.session self.session
); );
}); });
self.step.resolve(self.stepWaitForAcceptWait()); 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();
} }
} }
@@ -442,8 +516,20 @@ export class Command {
return "#6ac"; return "#6ac";
} }
builder(info, config, session, streams, subs, controls, history) { wizard(info, preset, session, streams, subs, controls, history) {
return new Wizard(info, config, 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) { launch(info, launcher, streams, subs, controls, history) {
@@ -476,7 +562,7 @@ export class Command {
} }
} }
return this.builder( return this.execute(
info, info,
{ {
host: d[0], host: d[0],
@@ -493,4 +579,14 @@ export class Command {
launcher(config) { launcher(config) {
return config.host + "|" + (config.charset ? config.charset : "utf-8"); 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; 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 */
.icon { .icon {
line-height: 1; line-height: 1;

View File

@@ -110,6 +110,8 @@
:inputting="connector.inputting" :inputting="connector.inputting"
:display="windows.connect" :display="windows.connect"
:connectors="connector.connectors" :connectors="connector.connectors"
:presets="presets"
:restricted-to-presets="restrictedToPresets"
:knowns="connector.knowns" :knowns="connector.knowns"
:knowns-launcher-builder="buildknownLauncher" :knowns-launcher-builder="buildknownLauncher"
:knowns-export="exportKnowns" :knowns-export="exportKnowns"
@@ -119,6 +121,7 @@
@connector-select="connectNew" @connector-select="connectNew"
@known-select="connectKnown" @known-select="connectKnown"
@known-remove="removeKnown" @known-remove="removeKnown"
@preset-select="connectPreset"
@known-clear-session="clearSessionKnown" @known-clear-session="clearSessionKnown"
> >
<connector <connector
@@ -143,8 +146,9 @@
@current="switchTab" @current="switchTab"
@retap="retapTab" @retap="retapTab"
@close="closeTab" @close="closeTab"
></tab-window></div ></tab-window>
></template> </div>
</template>
<script> <script>
import "./home.css"; import "./home.css";
@@ -159,6 +163,8 @@ import Screens from "./widgets/screens.vue";
import * as home_socket from "./home_socketctl.js"; import * as home_socket from "./home_socketctl.js";
import * as home_history from "./home_historyctl.js"; import * as home_history from "./home_historyctl.js";
import * as presets from "./commands/presets.js";
const BACKEND_CONNECT_ERROR = const BACKEND_CONNECT_ERROR =
"Unable to connect to the Sshwifty backend server: "; "Unable to connect to the Sshwifty backend server: ";
const BACKEND_REQUEST_ERROR = "Unable to perform request: "; const BACKEND_REQUEST_ERROR = "Unable to perform request: ";
@@ -198,6 +204,18 @@ export default {
default: () => { default: () => {
return null; return null;
} }
},
presetData: {
type: Object,
default: () => {
return new presets.Presets([]);
}
},
restrictedToPresets: {
type: Boolean,
default: () => {
return false;
}
} }
}, },
data() { data() {
@@ -220,6 +238,7 @@ export default {
busy: false, busy: false,
knowns: history.all() knowns: history.all()
}, },
presets: this.commands.mergePresets(this.presetData),
tab: { tab: {
current: -1, current: -1,
lastID: 0, lastID: 0,
@@ -317,22 +336,45 @@ export default {
); );
}, },
connectNew(connector) { connectNew(connector) {
this.runConnect(stream => { const self = this;
this.connector.connector = {
self.runConnect(stream => {
self.connector.connector = {
id: connector.id(), id: connector.id(),
name: connector.name(), name: connector.name(),
description: connector.description(), description: connector.description(),
wizard: connector.build( wizard: connector.wizard(
stream, stream,
this.controls, self.controls,
this.connector.historyRec, self.connector.historyRec,
null, presets.emptyPreset(),
null, 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) { getConnectorByType(type) {
@@ -349,27 +391,27 @@ export default {
return connector; return connector;
}, },
connectKnown(known) { connectKnown(known) {
this.runConnect(stream => { const self = this;
let connector = this.getConnectorByType(known.type);
self.runConnect(stream => {
let connector = self.getConnectorByType(known.type);
if (!connector) { if (!connector) {
alert("Unknown connector: " + known.type); alert("Unknown connector: " + known.type);
this.connector.inputting = false; self.connector.inputting = false;
return; return;
} }
const self = this; self.connector.connector = {
this.connector.connector = {
id: connector.id(), id: connector.id(),
name: connector.name(), name: connector.name(),
description: connector.description(), description: connector.description(),
wizard: connector.build( wizard: connector.execute(
stream, stream,
this.controls, self.controls,
this.connector.historyRec, self.connector.historyRec,
known.data, known.data,
known.session, known.session,
() => { () => {
@@ -378,7 +420,7 @@ export default {
) )
}; };
this.connector.inputting = true; self.connector.inputting = true;
}); });
}, },
parseConnectLauncher(ll) { parseConnectLauncher(ll) {

View File

@@ -44,11 +44,14 @@
<connect-known <connect-known
v-if="tab === 'known' && !inputting" v-if="tab === 'known' && !inputting"
:presets="presets"
:restricted-to-presets="restrictedToPresets"
:knowns="knowns" :knowns="knowns"
:launcher-builder="knownsLauncherBuilder" :launcher-builder="knownsLauncherBuilder"
:knowns-export="knownsExport" :knowns-export="knownsExport"
:knowns-import="knownsImport" :knowns-import="knownsImport"
@select="selectKnown" @select="selectKnown"
@select-preset="selectPreset"
@remove="removeKnown" @remove="removeKnown"
@clear-session="clearSessionKnown" @clear-session="clearSessionKnown"
></connect-known> ></connect-known>
@@ -100,6 +103,14 @@ export default {
type: Boolean, type: Boolean,
default: false default: false
}, },
presets: {
type: Array,
default: () => []
},
restrictedToPresets: {
type: Boolean,
default: () => false
},
knowns: { knowns: {
type: Array, type: Array,
default: () => [] default: () => []
@@ -160,6 +171,13 @@ export default {
this.$emit("known-remove", uid); this.$emit("known-remove", uid);
}, },
selectPreset(preset) {
if (this.inputting) {
return;
}
this.$emit("preset-select", preset);
},
clearSessionKnown(uid) { clearSessionKnown(uid) {
if (this.inputting) { if (this.inputting) {
return; return;

View File

@@ -22,13 +22,20 @@
#connect-known-list { #connect-known-list {
min-height: 200px; min-height: 200px;
font-size: 0.75em; font-size: 0.75em;
padding: 15px;
background: #3a3a3a; background: #3a3a3a;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
} }
#connect-known-list h3 {
font-size: 1.1em;
color: #999;
margin: 5px 0 15px 0;
text-transform: uppercase;
font-weight: bold;
}
#connect-known-list.reloaded { #connect-known-list.reloaded {
} }
@@ -62,10 +69,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 20px;
} }
#connect-known-list-import { #connect-known-list-import {
margin: 15px 0 10px 0; margin: 15px 0;
color: #aaa; color: #aaa;
font-size: 1.1em; font-size: 1.1em;
text-align: center; text-align: center;
@@ -76,26 +84,30 @@
text-decoration: none; text-decoration: none;
} }
#connect-known-list li { #connect-known-list-list {
padding: 15px 20px 20px 20px;
}
#connect-known-list-list li {
width: 50%; width: 50%;
position: relative; position: relative;
} }
@media (max-width: 480px) { @media (max-width: 480px) {
#connect-known-list li { #connect-known-list-list li {
width: 100%; width: 100%;
} }
} }
#connect-known-list li .lst-wrap { #connect-known-list-list li > .lst-wrap {
cursor: pointer; cursor: pointer;
} }
#connect-known-list li .lst-wrap:hover { #connect-known-list-list li > .lst-wrap:hover {
background: #444; background: #444;
} }
#connect-known-list li .labels { #connect-known-list-list li > .labels {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -104,14 +116,14 @@
letter-spacing: 1px; letter-spacing: 1px;
} }
#connect-known-list li .labels > .type { #connect-known-list-list li > .labels > .type {
display: inline-block; display: inline-block;
padding: 3px; padding: 3px;
background: #a56; background: #a56;
color: #fff; color: #fff;
} }
#connect-known-list li .labels > .opt { #connect-known-list-list li > .labels > .opt {
display: none; display: none;
padding: 3px; padding: 3px;
background: #a56; background: #a56;
@@ -121,43 +133,44 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
#connect-known-list li .labels > .opt { #connect-known-list-list li > .labels > .opt {
display: inline-block; display: inline-block;
} }
} }
#connect-known-list li .labels > .opt.link { #connect-known-list-list li > .labels > .opt.link {
background: #287; background: #287;
color: #fff; color: #fff;
} }
#connect-known-list li .labels > .opt.link:after { #connect-known-list-list li > .labels > .opt.link:after {
content: "\02936"; content: "\02936";
} }
#connect-known-list li .labels > .opt.del { #connect-known-list-list li > .labels > .opt.del {
background: #a56; background: #a56;
color: #fff; color: #fff;
} }
#connect-known-list li .labels > .opt.clr { #connect-known-list-list li > .labels > .opt.clr {
background: #b71; background: #b71;
color: #fff; color: #fff;
} }
#connect-known-list li:hover .labels > .opt, #connect-known-list-list li:hover > .labels > .opt,
#connect-known-list li:focus .labels > .opt { #connect-known-list-list li:focus > .labels > .opt {
display: inline-block; display: inline-block;
} }
#connect-known-list li h2 { #connect-known-list-list li > .lst-wrap > h4 {
font-size: 1.5em;
margin-top: 5px; margin-top: 5px;
margin-bottom: 5px; margin-bottom: 5px;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
} }
#connect-known-list li h2::before { #connect-known-list-list li > .lst-wrap > h4::before {
content: ">_"; content: ">_";
color: #555; color: #555;
font-size: 0.8em; font-size: 0.8em;
@@ -167,7 +180,70 @@
border-radius: 2px; border-radius: 2px;
} }
#connect-known-list li h2.highlight::before { #connect-known-list-list li > .lst-wrap > h4.highlight::before {
color: #eee; color: #eee;
background: #555; 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,13 +19,23 @@
<template> <template>
<div id="connect-known-list" :class="{ reloaded: reloaded }"> <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 No known remote available
</div> </div>
<ul v-else id="connect-known-list-list" class="hlst lstcl1"> <div v-else>
<div v-if="knownList.length > 0" id="connect-known-list-list">
<h3>Connected before</h3>
<ul class="hlst lstcl1">
<li v-for="(known, kk) in knownList" :key="kk"> <li v-for="(known, kk) in knownList" :key="kk">
<div class="labels"> <div class="labels">
<span class="type" :style="'background-color: ' + known.data.color"> <span
class="type"
:style="'background-color: ' + known.data.color"
>
{{ known.data.type }} {{ known.data.type }}
</span> </span>
@@ -55,17 +65,58 @@
Clear Clear
</a> </a>
</div> </div>
<div class="lst-wrap" @click="select(known.data)"> <div class="lst-wrap" @click="select(known.data)">
<h2 <h4
:title="known.data.title" :title="known.data.title"
:class="{ highlight: known.data.session }" :class="{ highlight: known.data.session }"
> >
{{ known.data.title }} {{ known.data.title }}
</h2> </h4>
Last: {{ known.data.last.toLocaleString() }} Last: {{ known.data.last.toLocaleString() }}
</div> </div>
</li> </li>
</ul> </ul>
</div>
<div
v-if="presets.length > 0"
id="connect-known-list-presets"
:class="{
'last-planel': knownList.length > 0
}"
>
<h3>Presets</h3>
<ul class="hlst lstcl2">
<li
v-for="(preset, pk) in presets"
:key="pk"
:class="{ disabled: presetDisabled(preset) }"
>
<div class="lst-wrap" @click="selectPreset(preset)">
<div class="labels">
<span
class="type"
:style="'background-color: ' + preset.command.color()"
>
{{ preset.command.name() }}
</span>
</div>
<h4 :title="preset.preset.title()">
{{ preset.preset.title() }}
</h4>
</div>
</li>
</ul>
<div v-if="restrictedToPresets" id="connect-known-list-presets-alert">
The operator has disabled outgoing access to all remote hosts except
those been defined as preset.
</div>
</div>
</div>
<div id="connect-known-list-import"> <div id="connect-known-list-import">
Tip: You can Tip: You can
@@ -81,6 +132,14 @@ import "./connect_known.css";
export default { export default {
props: { props: {
presets: {
type: Array,
default: () => []
},
restrictedToPresets: {
type: Boolean,
default: () => false
},
knowns: { knowns: {
type: Array, type: Array,
default: () => [] default: () => []
@@ -147,6 +206,20 @@ export default {
this.$emit("select", known); 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) { async launcher(known, ev) {
if (known.copying || this.busy) { if (known.copying || this.busy) {
return; return;

View File

@@ -15,7 +15,7 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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) => { return new Promise((res, rej) => {
let authReq = new XMLHttpRequest(); let authReq = new XMLHttpRequest();
@@ -35,7 +35,7 @@ export function head(url, headers) {
rej(e); rej(e);
}; };
authReq.open("HEAD", url, true); authReq.open("GET", url, true);
for (let h in headers) { for (let h in headers) {
authReq.setRequestHeader(h, headers[h]); authReq.setRequestHeader(h, headers[h]);