push for test
This commit is contained in:
commit
a13bd8caed
41
DEPENDENCIES.md
Normal file
41
DEPENDENCIES.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Dependencies used by Sshwifty
|
||||
|
||||
Sshwifty uses many third-party components. Those components is required in order
|
||||
for Sshwifty to function.
|
||||
|
||||
A list of used components can be found inside `package.json` and `go.mod` file.
|
||||
|
||||
Major dependencies includes:
|
||||
|
||||
## For front-end application
|
||||
|
||||
- [Vue](https://vuejs.org), Licensed under MIT license
|
||||
- [Babel](https://babeljs.io/), Licensed under MIT license
|
||||
- [XTerm.js](https://xtermjs.org/), Licensed under MIT license
|
||||
- [normalize.css](https://github.com/necolas/normalize.css), Licensed under MIT license
|
||||
- [Roboto font](https://en.wikipedia.org/wiki/Roboto), Licensed under Apache license
|
||||
Packaged by [Christian Hoffmeister](https://github.com/choffmeister/roboto-fontface-bower), Licensed under Apache 2.0
|
||||
- [iconv-lite](https://github.com/ashtuchkin/iconv-lite), Licensed under MIT license
|
||||
- [buffer](https://github.com/feross/buffer), Licensed under MIT license
|
||||
- [fontfaceobserver](https://github.com/bramstein/fontfaceobserver), [View license](https://github.com/bramstein/fontfaceobserver/blob/master/LICENSE)
|
||||
- [Hack Font](https://github.com/source-foundry/Hack), [View license](https://github.com/source-foundry/Hack/blob/master/LICENSE.md)
|
||||
- [Nerd Fonts](https://www.nerdfonts.com/), packaged by [@azurity/pure-nerd-font](http://github.com/azurity/pure-nerd-font)
|
||||
includes icons from following fonts:
|
||||
- [Powerline Extra Symbols](https://github.com/ryanoasis/powerline-extra-symbols), Licensed under MIT license
|
||||
- [Font Awesome](https://github.com/FortAwesome/Font-Awesome), [View license](https://github.com/FortAwesome/Font-Awesome/blob/6.x/LICENSE.txt)
|
||||
- [Font Awesome Extension](https://github.com/AndreLZGava/font-awesome-extension), Licensed under MIT license
|
||||
- [Material Design Icons](https://github.com/Templarian/MaterialDesign), [View license](https://github.com/Templarian/MaterialDesign/blob/master/LICENSE)
|
||||
- [Weather Icons](https://github.com/erikflowers/weather-icons), Licensed under SIL OFL 1.1
|
||||
- [Devicons](https://github.com/vorillaz/devicons), Licensed under MIT license
|
||||
- [Octicons](https://github.com/primer/octicons), Licensed under MIT license
|
||||
- [Codicons](https://github.com/microsoft/vscode-codicons), Licensed under MIT License
|
||||
- [Font Logos (Formerly Font Linux)](https://github.com/Lukas-W/font-logos), Licensed under Unlicense license
|
||||
- [Pomicons](https://github.com/gabrielelana/pomicons), Licensed under OFL-1.1 license
|
||||
- ... and more, see [full list](https://github.com/ryanoasis/nerd-fonts/tree/master/src/glyphs)
|
||||
|
||||
## For back-end application
|
||||
|
||||
- [Go programming language](https://golang.org), [View license](https://github.com/golang/go/blob/master/LICENSE)
|
||||
- `github.com/gorilla/websocket`, Licensed under BSD-2-Cause license
|
||||
- `golang.org/x/net/proxy` [View license](https://github.com/golang/net/blob/master/LICENSE)
|
||||
- `golang.org/x/crypto`, [View license](https://github.com/golang/crypto/blob/master/LICENSE)
|
||||
79
Dockerfile
Normal file
79
Dockerfile
Normal file
@ -0,0 +1,79 @@
|
||||
# Build the build base environment
|
||||
FROM ubuntu:devel AS base
|
||||
RUN set -ex && \
|
||||
cd / && \
|
||||
echo '#!/bin/sh' > /try.sh && echo 'res=1; for i in $(seq 0 36); do $@; res=$?; [ $res -eq 0 ] && exit $res || sleep 10; done; exit $res' >> /try.sh && chmod +x /try.sh && \
|
||||
echo '#!/bin/sh' > /child.sh && echo 'cpid=""; ret=0; i=0; for c in "$@"; do ( (((((eval "$c"; echo $? >&3) | sed "s/^/|-($i) /" >&4) 2>&1 | sed "s/^/|-($i)!/" >&2) 3>&1) | (read xs; exit $xs)) 4>&1) & ppid=$!; cpid="$cpid $ppid"; echo "+ Child $i (PID $ppid): $c ..."; i=$((i+1)); done; for c in $cpid; do wait $c; cret=$?; [ $cret -eq 0 ] && continue; echo "* Child PID $c has failed." >&2; ret=$cret; done; exit $ret' >> /child.sh && chmod +x /child.sh && \
|
||||
export PATH=$PATH:/ && \
|
||||
export DEBIAN_FRONTEND=noninteractive && \
|
||||
([ -z "$HTTP_PROXY" ] || (echo "Acquire::http::Proxy \"$HTTP_PROXY\";" >> /etc/apt/apt.conf)) && \
|
||||
([ -z "$HTTPS_PROXY" ] || (echo "Acquire::https::Proxy \"$HTTPS_PROXY\";" >> /etc/apt/apt.conf)) && \
|
||||
(echo "Acquire::Retries \"8\";" >> /etc/apt/apt.conf) && \
|
||||
echo '#!/bin/sh' > /install.sh && echo 'apt-get -y update && apt-get -y --fix-broken install autoconf automake libtool build-essential ca-certificates curl git nodejs npm golang-go libvips libvips-dev' >> /install.sh && chmod +x /install.sh && \
|
||||
/try.sh /install.sh && rm /install.sh && \
|
||||
/try.sh update-ca-certificates -f && c_rehash && \
|
||||
([ -z "$HTTP_PROXY" ] || (git config --global http.proxy "$HTTP_PROXY" && npm config set proxy "$HTTP_PROXY")) && \
|
||||
([ -z "$HTTPS_PROXY" ] || (git config --global https.proxy "$HTTPS_PROXY" && npm config set https-proxy "$HTTPS_PROXY")) && \
|
||||
export PATH=$PATH:"$(go env GOPATH)/bin" && \
|
||||
([ -z "$CUSTOM_COMMAND" ] || (echo "Running custom command: $CUSTOM_COMMAND" && $CUSTOM_COMMAND)) && \
|
||||
echo '#!/bin/sh' > /install.sh && echo "(npm install -g n && n stable) || (npm cache clean -f && false)" >> /install.sh && chmod +x /install.sh && /try.sh /install.sh && rm /install.sh && \
|
||||
git version && \
|
||||
go version && \
|
||||
npm version
|
||||
|
||||
# Build the base environment for application libraries
|
||||
FROM base AS libbase
|
||||
COPY . /tmp/.build/sshwifty
|
||||
RUN set -ex && \
|
||||
cd / && \
|
||||
export PATH=$PATH:/ && \
|
||||
export DEBIAN_FRONTEND=noninteractive && \
|
||||
export CPPFLAGS='-DPNG_ARM_NEON_OPT=0' && \
|
||||
/try.sh apt-get install libpng-dev -y && \
|
||||
ls -l /tmp/.build/sshwifty && \
|
||||
/child.sh \
|
||||
"cd /tmp/.build/sshwifty && echo '#!/bin/sh' > /npm_install.sh && echo \"npm install || (npm cache clean -f && rm ~/.npm/_* -rf && false)\" >> /npm_install.sh && chmod +x /npm_install.sh && /try.sh /npm_install.sh && rm /npm_install.sh" \
|
||||
'cd /tmp/.build/sshwifty && /try.sh go mod download'
|
||||
|
||||
# Main building environment
|
||||
FROM libbase AS builder
|
||||
RUN set -ex && \
|
||||
cd / && \
|
||||
export PATH=$PATH:/ && \
|
||||
([ -z "$HTTP_PROXY" ] || (git config --global http.proxy "$HTTP_PROXY" && npm config set proxy "$HTTP_PROXY")) && \
|
||||
([ -z "$HTTPS_PROXY" ] || (git config --global https.proxy "$HTTPS_PROXY" && npm config set https-proxy "$HTTPS_PROXY")) && \
|
||||
(cd /tmp/.build/sshwifty && /try.sh npm run build && mv ./sshwifty /)
|
||||
|
||||
# Build the final image for running
|
||||
FROM alpine:latest
|
||||
ENV SSHWIFTY_HOSTNAME= \
|
||||
SSHWIFTY_SHAREDKEY= \
|
||||
SSHWIFTY_DIALTIMEOUT=10 \
|
||||
SSHWIFTY_SOCKS5= \
|
||||
SSHWIFTY_SOCKS5_USER= \
|
||||
SSHWIFTY_SOCKS5_PASSWORD= \
|
||||
SSHWIFTY_LISTENINTERFACE=0.0.0.0 \
|
||||
SSHWIFTY_LISTENPORT=8182 \
|
||||
SSHWIFTY_INITIALTIMEOUT=0 \
|
||||
SSHWIFTY_READTIMEOUT=0 \
|
||||
SSHWIFTY_WRITETIMEOUT=0 \
|
||||
SSHWIFTY_HEARTBEATTIMEOUT=0 \
|
||||
SSHWIFTY_READDELAY=0 \
|
||||
SSHWIFTY_WRITEELAY=0 \
|
||||
SSHWIFTY_TLSCERTIFICATEFILE= \
|
||||
SSHWIFTY_TLSCERTIFICATEKEYFILE= \
|
||||
SSHWIFTY_DOCKER_TLSCERT= \
|
||||
SSHWIFTY_DOCKER_TLSCERTKEY= \
|
||||
SSHWIFTY_PRESETS= \
|
||||
SSHWIFTY_SERVERMESSAGE= \
|
||||
SSHWIFTY_ONLYALLOWPRESETREMOTES=
|
||||
COPY --from=builder /sshwifty /
|
||||
COPY . /sshwifty-src
|
||||
RUN set -ex && \
|
||||
adduser -D sshwifty && \
|
||||
chmod +x /sshwifty && \
|
||||
echo '#!/bin/sh' > /sshwifty.sh && echo '([ -z "$SSHWIFTY_DOCKER_TLSCERT" ] || echo "$SSHWIFTY_DOCKER_TLSCERT" > /tmp/cert); ([ -z "$SSHWIFTY_DOCKER_TLSCERTKEY" ] || echo "$SSHWIFTY_DOCKER_TLSCERTKEY" > /tmp/certkey); if [ -f "/tmp/cert" ] && [ -f "/tmp/certkey" ]; then SSHWIFTY_TLSCERTIFICATEFILE=/tmp/cert SSHWIFTY_TLSCERTIFICATEKEYFILE=/tmp/certkey /sshwifty; else /sshwifty; fi;' >> /sshwifty.sh && chmod +x /sshwifty.sh
|
||||
USER sshwifty
|
||||
EXPOSE 8182
|
||||
ENTRYPOINT [ "/sshwifty.sh" ]
|
||||
CMD []
|
||||
660
LICENSE.md
Normal file
660
LICENSE.md
Normal file
@ -0,0 +1,660 @@
|
||||
### GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
### Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains
|
||||
free software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing
|
||||
under this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
### TERMS AND CONDITIONS
|
||||
|
||||
#### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public
|
||||
License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
#### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
#### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
#### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
#### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
#### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
#### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
#### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
#### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
#### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
#### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
#### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
#### 13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your
|
||||
version supports such interaction) an opportunity to receive the
|
||||
Corresponding Source of your version by providing access to the
|
||||
Corresponding Source from a network server at no charge, through some
|
||||
standard or customary means of facilitating copying of software. This
|
||||
Corresponding Source shall include the Corresponding Source for any
|
||||
work covered by version 3 of the GNU General Public License that is
|
||||
incorporated pursuant to the following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
#### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Affero General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever
|
||||
published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
#### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
#### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
#### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
### How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for
|
||||
the specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
||||
26
README.md
Normal file
26
README.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Sshwifty Web SSH & Telnet Client
|
||||
|
||||
**Sshwifty is a SSH and Telnet connector made for the Web.**
|
||||
|
||||

|
||||
|
||||
## déployer
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
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] to learn more about dependencies used by this
|
||||
project and read their copyright statements.
|
||||
|
||||
[LICENSE.md]: LICENSE.md
|
||||
[DEPENDENCIES.md]: DEPENDENCIES.md
|
||||
|
||||
## Contribuer
|
||||
|
||||
- [ ] traduire en français
|
||||
BIN
Screenshot.png
Normal file
BIN
Screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
192
application/application.go
Normal file
192
application/application.go
Normal file
@ -0,0 +1,192 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
goLog "log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/nirui/sshwifty/application/command"
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/server"
|
||||
)
|
||||
|
||||
// ProccessSignaller send signal to the running application
|
||||
type ProccessSignaller chan os.Signal
|
||||
|
||||
// ProccessSignallerBuilder builds a ProccessSignaler
|
||||
type ProccessSignallerBuilder func() chan os.Signal
|
||||
|
||||
// DefaultProccessSignallerBuilder the default ProccessSignallerBuilder
|
||||
func DefaultProccessSignallerBuilder() chan os.Signal {
|
||||
return make(chan os.Signal, 1)
|
||||
}
|
||||
|
||||
var (
|
||||
screenLineWipper = []byte("\r")
|
||||
)
|
||||
|
||||
// Application contains data required for the application, and yes I don't like
|
||||
// to write comments
|
||||
type Application struct {
|
||||
screen io.Writer
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// New creates a new Application
|
||||
func New(screen io.Writer, logger log.Logger) Application {
|
||||
return Application{
|
||||
screen: screen,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Run execute the application. It will return when the application is finished
|
||||
// running
|
||||
func (a Application) run(
|
||||
cLoader configuration.Loader,
|
||||
closeSigBuilder ProccessSignallerBuilder,
|
||||
commands command.Commands,
|
||||
handlerBuilder server.HandlerBuilderBuilder,
|
||||
) (bool, error) {
|
||||
var err error
|
||||
|
||||
loaderName, c, cErr := cLoader(a.logger.Context("Configuration"))
|
||||
|
||||
if cErr != nil {
|
||||
a.logger.Error("\"%s\" loader cannot load configuration: %s",
|
||||
loaderName, cErr)
|
||||
|
||||
return false, cErr
|
||||
}
|
||||
|
||||
// Allowing command to alter presets
|
||||
c.Presets, err = commands.Reconfigure(c.Presets)
|
||||
|
||||
if err != nil {
|
||||
a.logger.Error("Unable to reconfigure presets: %s", err)
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Verify all configuration
|
||||
err = c.Verify()
|
||||
|
||||
if err != nil {
|
||||
a.logger.Error("Configuration was invalid: %s", err)
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
closeNotify := closeSigBuilder()
|
||||
closeNotifyDisableLock := sync.Mutex{}
|
||||
signal.Notify(closeNotify, os.Kill, os.Interrupt, syscall.SIGHUP)
|
||||
defer func() {
|
||||
closeNotifyDisableLock.Lock()
|
||||
defer closeNotifyDisableLock.Unlock()
|
||||
if closeNotify == nil {
|
||||
return
|
||||
}
|
||||
signal.Stop(closeNotify)
|
||||
close(closeNotify)
|
||||
closeNotify = nil
|
||||
}()
|
||||
|
||||
servers := make([]*server.Serving, 0, len(c.Servers))
|
||||
s := server.New(a.logger)
|
||||
|
||||
defer func() {
|
||||
for i := len(servers); i > 0; i-- {
|
||||
servers[i-1].Close()
|
||||
}
|
||||
s.Wait()
|
||||
}()
|
||||
|
||||
for _, ss := range c.Servers {
|
||||
newServer := s.Serve(c.Common(), ss, func(e error) {
|
||||
closeNotifyDisableLock.Lock()
|
||||
defer closeNotifyDisableLock.Unlock()
|
||||
if closeNotify == nil {
|
||||
return
|
||||
}
|
||||
err = e
|
||||
signal.Stop(closeNotify)
|
||||
close(closeNotify)
|
||||
closeNotify = nil
|
||||
}, handlerBuilder(commands))
|
||||
servers = append(servers, newServer)
|
||||
}
|
||||
|
||||
switch <-closeNotify {
|
||||
case syscall.SIGHUP:
|
||||
return true, nil
|
||||
case syscall.SIGTERM:
|
||||
fallthrough
|
||||
case os.Kill:
|
||||
fallthrough
|
||||
case os.Interrupt:
|
||||
a.screen.Write(screenLineWipper)
|
||||
return false, nil
|
||||
default:
|
||||
closeNotifyDisableLock.Lock()
|
||||
defer closeNotifyDisableLock.Unlock()
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Run execute the application. It will return when the application is finished
|
||||
// running
|
||||
func (a Application) Run(
|
||||
cLoader configuration.Loader,
|
||||
closeSigBuilder ProccessSignallerBuilder,
|
||||
commands command.Commands,
|
||||
handlerBuilder server.HandlerBuilderBuilder,
|
||||
) error {
|
||||
fmt.Fprintf(a.screen, banner, FullName, version, Author, URL)
|
||||
|
||||
goLog.SetOutput(a.logger)
|
||||
defer goLog.SetOutput(os.Stderr)
|
||||
|
||||
a.logger.Info("Initializing")
|
||||
defer a.logger.Info("Closed")
|
||||
|
||||
for {
|
||||
restart, runErr := a.run(
|
||||
cLoader, closeSigBuilder, commands, handlerBuilder)
|
||||
|
||||
if runErr != nil {
|
||||
a.logger.Error("Unable to start due to error: %s", runErr)
|
||||
|
||||
return runErr
|
||||
}
|
||||
|
||||
if restart {
|
||||
a.logger.Info("Restarting")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
68
application/command/commander.go
Normal file
68
application/command/commander.go
Normal file
@ -0,0 +1,68 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/network"
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Configuration contains configuration data needed to run command
|
||||
type Configuration struct {
|
||||
Dial network.Dial
|
||||
DialTimeout time.Duration
|
||||
}
|
||||
|
||||
// Commander command control
|
||||
type Commander struct {
|
||||
commands Commands
|
||||
}
|
||||
|
||||
// New creates a new Commander
|
||||
func New(cs Commands) Commander {
|
||||
return Commander{
|
||||
commands: cs,
|
||||
}
|
||||
}
|
||||
|
||||
// New Adds a new client
|
||||
func (c Commander) New(
|
||||
cfg Configuration,
|
||||
receiver rw.FetchReader,
|
||||
sender io.Writer,
|
||||
senderLock *sync.Mutex,
|
||||
receiveDelay time.Duration,
|
||||
sendDelay time.Duration,
|
||||
l log.Logger,
|
||||
) (Handler, error) {
|
||||
return newHandler(
|
||||
cfg,
|
||||
&c.commands,
|
||||
receiver,
|
||||
sender,
|
||||
senderLock,
|
||||
receiveDelay,
|
||||
sendDelay,
|
||||
l,
|
||||
), nil
|
||||
}
|
||||
128
application/command/commands.go
Normal file
128
application/command/commands.go
Normal file
@ -0,0 +1,128 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
// Consts
|
||||
const (
|
||||
MaxCommandID = 0x0f
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrCommandRunUndefinedCommand = errors.New(
|
||||
"undefined Command")
|
||||
)
|
||||
|
||||
// Command represents a command handler machine builder
|
||||
type Command func(
|
||||
l log.Logger,
|
||||
w StreamResponder,
|
||||
cfg Configuration,
|
||||
) FSMMachine
|
||||
|
||||
// Builder builds a command
|
||||
type Builder struct {
|
||||
name string
|
||||
command Command
|
||||
configurator configuration.PresetReloader
|
||||
}
|
||||
|
||||
// Register builds a Builder for registration
|
||||
func Register(name string, c Command, p configuration.PresetReloader) Builder {
|
||||
return Builder{
|
||||
name: name,
|
||||
command: c,
|
||||
configurator: p,
|
||||
}
|
||||
}
|
||||
|
||||
// Commands contains data of all commands
|
||||
type Commands [MaxCommandID + 1]Builder
|
||||
|
||||
// Register registers a new command
|
||||
func (c *Commands) Register(
|
||||
id byte,
|
||||
name string,
|
||||
cb Command,
|
||||
ps configuration.PresetReloader,
|
||||
) {
|
||||
if id > MaxCommandID {
|
||||
panic("Command ID must be not greater than MaxCommandID")
|
||||
}
|
||||
|
||||
if (*c)[id].command != nil {
|
||||
panic(fmt.Sprintf("Command %d already been registered", id))
|
||||
}
|
||||
|
||||
(*c)[id] = Register(name, cb, ps)
|
||||
}
|
||||
|
||||
// Run creates command executer
|
||||
func (c Commands) Run(
|
||||
id byte,
|
||||
l log.Logger,
|
||||
w StreamResponder,
|
||||
cfg Configuration,
|
||||
) (FSM, error) {
|
||||
if id > MaxCommandID {
|
||||
return FSM{}, ErrCommandRunUndefinedCommand
|
||||
}
|
||||
|
||||
cc := c[id]
|
||||
|
||||
if cc.command == nil {
|
||||
return FSM{}, ErrCommandRunUndefinedCommand
|
||||
}
|
||||
|
||||
return newFSM(cc.command(l, w, cfg)), nil
|
||||
}
|
||||
|
||||
// Reconfigure lets commands reset configuration
|
||||
func (c Commands) Reconfigure(
|
||||
p []configuration.Preset,
|
||||
) ([]configuration.Preset, error) {
|
||||
newP := make([]configuration.Preset, 0, len(p))
|
||||
|
||||
for i := range c {
|
||||
for pp := range p {
|
||||
if c[i].name != p[pp].Type {
|
||||
continue
|
||||
}
|
||||
|
||||
newPP, pErr := c[i].configurator(p[pp])
|
||||
|
||||
if pErr == nil {
|
||||
newP = append(newP, newPP)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, pErr
|
||||
}
|
||||
}
|
||||
|
||||
return newP, nil
|
||||
}
|
||||
176
application/command/fsm.go
Normal file
176
application/command/fsm.go
Normal file
@ -0,0 +1,176 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrFSMMachineClosed = errors.New(
|
||||
"FSM Machine is already closed, it cannot do anything but be released")
|
||||
)
|
||||
|
||||
// FSMError Represents an error from FSM
|
||||
type FSMError struct {
|
||||
code StreamError
|
||||
message string
|
||||
succeed bool
|
||||
}
|
||||
|
||||
// ToFSMError converts error to FSMError
|
||||
func ToFSMError(e error, c StreamError) FSMError {
|
||||
return FSMError{
|
||||
code: c,
|
||||
message: e.Error(),
|
||||
succeed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NoFSMError return a FSMError that represents a success operation
|
||||
func NoFSMError() FSMError {
|
||||
return FSMError{
|
||||
code: 0,
|
||||
message: "No error",
|
||||
succeed: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Error return the error message
|
||||
func (e FSMError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
// Code return the error code
|
||||
func (e FSMError) Code() StreamError {
|
||||
return e.code
|
||||
}
|
||||
|
||||
// Succeed returns whether or not current error represents a succeed operation
|
||||
func (e FSMError) Succeed() bool {
|
||||
return e.succeed
|
||||
}
|
||||
|
||||
// FSMState represents a state of a machine
|
||||
type FSMState func(f *FSM, r *rw.LimitedReader, h StreamHeader, b []byte) error
|
||||
|
||||
// FSMMachine State machine
|
||||
type FSMMachine interface {
|
||||
// Bootup boots up the machine
|
||||
Bootup(r *rw.LimitedReader, b []byte) (FSMState, FSMError)
|
||||
|
||||
// Close stops the machine and get it ready for release.
|
||||
//
|
||||
// NOTE: Close function is responsible in making sure the HeaderClose signal
|
||||
// is sent before it returns.
|
||||
// (It may not need to send the header by itself, but it have to
|
||||
// make sure the header is sent)
|
||||
Close() error
|
||||
|
||||
// Release shuts the machine down completely and release it's resources
|
||||
Release() error
|
||||
}
|
||||
|
||||
// FSM state machine control
|
||||
type FSM struct {
|
||||
m FSMMachine
|
||||
s FSMState
|
||||
closed bool
|
||||
}
|
||||
|
||||
// newFSM creates a new FSM
|
||||
func newFSM(m FSMMachine) FSM {
|
||||
return FSM{
|
||||
m: m,
|
||||
s: nil,
|
||||
closed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// emptyFSM creates a empty FSM
|
||||
func emptyFSM() FSM {
|
||||
return FSM{
|
||||
m: nil,
|
||||
s: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// bootup initialize the machine
|
||||
func (f *FSM) bootup(r *rw.LimitedReader, b []byte) FSMError {
|
||||
s, err := f.m.Bootup(r, b)
|
||||
|
||||
if s == nil {
|
||||
panic("FSMState must not be nil")
|
||||
}
|
||||
|
||||
if !err.Succeed() {
|
||||
return err
|
||||
}
|
||||
|
||||
f.s = s
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// running returns whether or not current FSM is running
|
||||
func (f *FSM) running() bool {
|
||||
return f.s != nil
|
||||
}
|
||||
|
||||
// tick ticks current machine
|
||||
func (f *FSM) tick(r *rw.LimitedReader, h StreamHeader, b []byte) error {
|
||||
if f.closed {
|
||||
return ErrFSMMachineClosed
|
||||
}
|
||||
|
||||
return f.s(f, r, h, b)
|
||||
}
|
||||
|
||||
// Release shuts down current machine and release it's resource
|
||||
func (f *FSM) release() error {
|
||||
f.s = nil
|
||||
|
||||
if !f.closed {
|
||||
f.close()
|
||||
}
|
||||
|
||||
rErr := f.m.Release()
|
||||
|
||||
f.m = nil
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops the machine and get it ready to release
|
||||
func (f *FSM) close() error {
|
||||
f.closed = true
|
||||
|
||||
return f.m.Close()
|
||||
}
|
||||
|
||||
// Switch switch to specificied State for the next tick
|
||||
func (f *FSM) Switch(s FSMState) {
|
||||
f.s = s
|
||||
}
|
||||
369
application/command/handler.go
Normal file
369
application/command/handler.go
Normal file
@ -0,0 +1,369 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrHandlerUnknownHeaderType = errors.New(
|
||||
"unknown command header type")
|
||||
|
||||
ErrHandlerControlMessageTooLong = errors.New(
|
||||
"control message was too long")
|
||||
|
||||
ErrHandlerInvalidControlMessage = errors.New(
|
||||
"invalid control message")
|
||||
)
|
||||
|
||||
// HandlerCancelSignal signals the cancel of the entire handling proccess
|
||||
type HandlerCancelSignal chan struct{}
|
||||
|
||||
const (
|
||||
handlerReadBufLen = HeaderMaxData + 3 // (3 = 1 Header, 2 Etc)
|
||||
)
|
||||
|
||||
type handlerBuf [handlerReadBufLen]byte
|
||||
|
||||
// handlerSender writes handler signal
|
||||
type handlerSender struct {
|
||||
writer io.Writer
|
||||
lock *sync.Mutex
|
||||
needWait bool
|
||||
sign *sync.Cond
|
||||
}
|
||||
|
||||
// pause pauses sending
|
||||
func (h *handlerSender) pause() {
|
||||
h.lock.Lock()
|
||||
defer h.lock.Unlock()
|
||||
|
||||
h.needWait = true
|
||||
}
|
||||
|
||||
// resume resumes sending
|
||||
func (h *handlerSender) resume() {
|
||||
h.lock.Lock()
|
||||
defer h.lock.Unlock()
|
||||
|
||||
h.needWait = false
|
||||
h.sign.Broadcast()
|
||||
}
|
||||
|
||||
// signal sends handler signal
|
||||
func (h *handlerSender) signal(hd Header, d []byte, buf []byte) error {
|
||||
bufLen := len(buf)
|
||||
dLen := len(d)
|
||||
|
||||
if bufLen < dLen+1 {
|
||||
panic(fmt.Sprintf("Sending signal %s:%d requires %d bytes of buffer, "+
|
||||
"but only %d bytes is available", hd, d, dLen+1, bufLen))
|
||||
}
|
||||
|
||||
buf[0] = byte(hd)
|
||||
|
||||
wLen := copy(buf[1:], d) + 1
|
||||
|
||||
_, wErr := h.Write(buf[:wLen])
|
||||
|
||||
return wErr
|
||||
}
|
||||
|
||||
// Write sends data
|
||||
func (h *handlerSender) Write(b []byte) (int, error) {
|
||||
h.lock.Lock()
|
||||
defer h.lock.Unlock()
|
||||
|
||||
for h.needWait {
|
||||
h.sign.Wait()
|
||||
}
|
||||
|
||||
return h.writer.Write(b)
|
||||
}
|
||||
|
||||
// streamHandlerSender includes all receiver as handlerSender, but it been
|
||||
// designed to be use in streams
|
||||
type streamHandlerSender struct {
|
||||
*handlerSender
|
||||
|
||||
sendDelay time.Duration
|
||||
}
|
||||
|
||||
// Write sends data
|
||||
func (h streamHandlerSender) Write(b []byte) (int, error) {
|
||||
defer time.Sleep(h.sendDelay)
|
||||
|
||||
return h.handlerSender.Write(b)
|
||||
}
|
||||
|
||||
// Handler client stream control
|
||||
type Handler struct {
|
||||
cfg Configuration
|
||||
commands *Commands
|
||||
receiver rw.FetchReader
|
||||
sender handlerSender
|
||||
senderPaused bool
|
||||
receiveDelay time.Duration
|
||||
sendDelay time.Duration
|
||||
log log.Logger
|
||||
rBuf handlerBuf
|
||||
streams streams
|
||||
}
|
||||
|
||||
func newHandler(
|
||||
cfg Configuration,
|
||||
commands *Commands,
|
||||
receiver rw.FetchReader,
|
||||
sender io.Writer,
|
||||
senderLock *sync.Mutex,
|
||||
receiveDelay time.Duration,
|
||||
sendDelay time.Duration,
|
||||
l log.Logger,
|
||||
) Handler {
|
||||
return Handler{
|
||||
cfg: cfg,
|
||||
commands: commands,
|
||||
receiver: receiver,
|
||||
sender: handlerSender{
|
||||
writer: sender,
|
||||
lock: senderLock,
|
||||
needWait: false,
|
||||
sign: sync.NewCond(senderLock),
|
||||
},
|
||||
senderPaused: false,
|
||||
receiveDelay: receiveDelay,
|
||||
sendDelay: sendDelay,
|
||||
log: l,
|
||||
rBuf: handlerBuf{},
|
||||
streams: newStreams(),
|
||||
}
|
||||
}
|
||||
|
||||
// handleControl handles Control request
|
||||
//
|
||||
// Params:
|
||||
// - d: length of the control message
|
||||
//
|
||||
// Returns:
|
||||
// - error
|
||||
func (e *Handler) handleControl(d byte, l log.Logger) error {
|
||||
buf := e.rBuf[1:]
|
||||
|
||||
if len(buf) < int(d) {
|
||||
return ErrHandlerControlMessageTooLong
|
||||
}
|
||||
|
||||
rLen, rErr := io.ReadFull(&e.receiver, buf[:d])
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
if rLen <= 0 {
|
||||
return ErrHandlerInvalidControlMessage
|
||||
}
|
||||
|
||||
switch buf[0] {
|
||||
case HeaderControlEcho:
|
||||
l.Debug("Echo %d bytes", d)
|
||||
|
||||
hd := HeaderControl
|
||||
hd.Set(d)
|
||||
|
||||
e.rBuf[0] = byte(hd)
|
||||
e.rBuf[1] = HeaderControlEcho
|
||||
|
||||
var wErr error
|
||||
|
||||
if !e.senderPaused {
|
||||
_, wErr = e.sender.Write(e.rBuf[:rLen+1])
|
||||
} else {
|
||||
e.sender.lock.Lock()
|
||||
defer e.sender.lock.Unlock()
|
||||
|
||||
_, wErr = e.sender.writer.Write(e.rBuf[:rLen+1])
|
||||
}
|
||||
|
||||
return wErr
|
||||
|
||||
case HeaderControlPauseStream:
|
||||
if !e.senderPaused {
|
||||
e.sender.pause()
|
||||
e.senderPaused = true
|
||||
|
||||
l.Debug("Pause Stream")
|
||||
} else {
|
||||
l.Debug("Repeated Pause Stream command, ignore")
|
||||
}
|
||||
|
||||
case HeaderControlResumeStream:
|
||||
if e.senderPaused {
|
||||
e.sender.resume()
|
||||
e.senderPaused = false
|
||||
|
||||
l.Debug("Resume Stream")
|
||||
} else {
|
||||
l.Debug("Repeated Resume Stream command, ignore")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStream handles streams
|
||||
//
|
||||
// Params:
|
||||
// - d: Stream ID
|
||||
//
|
||||
// Returns:
|
||||
// - error
|
||||
func (e *Handler) handleStream(h Header, d byte, l log.Logger) error {
|
||||
st, stErr := e.streams.get(d)
|
||||
|
||||
if stErr != nil {
|
||||
return stErr
|
||||
}
|
||||
|
||||
// WARNING: stream.Tick and it's underlaying commands MUST NOT write to
|
||||
// client. This is because the client data writer maybe locked
|
||||
// and only current routine (the same routine will be used to
|
||||
// tick the stream) can unlock it.
|
||||
// Calling write may dead lock the routine, with there is no way
|
||||
// of recover.
|
||||
if st.running() {
|
||||
l.Debug("Ticking stream")
|
||||
|
||||
return st.tick(h, &e.receiver, e.rBuf[:])
|
||||
}
|
||||
|
||||
l.Debug("Start stream %d", h.Data())
|
||||
|
||||
if e.senderPaused {
|
||||
e.sender.resume()
|
||||
defer e.sender.pause()
|
||||
}
|
||||
|
||||
return st.reinit(h, &e.receiver, streamHandlerSender{
|
||||
handlerSender: &e.sender,
|
||||
sendDelay: e.sendDelay,
|
||||
}, l, e.commands, e.cfg, e.rBuf[:])
|
||||
}
|
||||
|
||||
func (e *Handler) handleClose(h Header, d byte, l log.Logger) error {
|
||||
st, stErr := e.streams.get(d)
|
||||
|
||||
if stErr != nil {
|
||||
return stErr
|
||||
}
|
||||
|
||||
if e.senderPaused {
|
||||
e.sender.resume()
|
||||
defer e.sender.pause()
|
||||
}
|
||||
|
||||
cErr := st.close()
|
||||
|
||||
if cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
|
||||
hhd := HeaderCompleted
|
||||
hhd.Set(h.Data())
|
||||
|
||||
return e.sender.signal(hhd, nil, e.rBuf[:])
|
||||
}
|
||||
|
||||
func (e *Handler) handleCompleted(d byte, l log.Logger) error {
|
||||
st, stErr := e.streams.get(d)
|
||||
|
||||
if stErr != nil {
|
||||
return stErr
|
||||
}
|
||||
|
||||
if e.senderPaused {
|
||||
e.sender.resume()
|
||||
defer e.sender.pause()
|
||||
}
|
||||
|
||||
return st.release()
|
||||
}
|
||||
|
||||
// Handle starts handling
|
||||
func (e *Handler) Handle() error {
|
||||
defer func() {
|
||||
if e.senderPaused {
|
||||
e.sender.resume()
|
||||
e.senderPaused = false
|
||||
}
|
||||
|
||||
e.streams.shutdown()
|
||||
}()
|
||||
|
||||
requests := 0
|
||||
|
||||
for {
|
||||
time.Sleep(e.receiveDelay)
|
||||
|
||||
requests++
|
||||
|
||||
d, dErr := rw.FetchOneByte(e.receiver.Fetch)
|
||||
|
||||
if dErr != nil {
|
||||
return dErr
|
||||
}
|
||||
|
||||
h := Header(d[0])
|
||||
l := e.log.Context("Request (%d)", requests).Context(h.String())
|
||||
|
||||
l.Debug("Received")
|
||||
|
||||
switch h.Type() {
|
||||
case HeaderControl:
|
||||
dErr = e.handleControl(h.Data(), l)
|
||||
|
||||
case HeaderStream:
|
||||
dErr = e.handleStream(h, h.Data(), l)
|
||||
|
||||
case HeaderClose:
|
||||
dErr = e.handleClose(h, h.Data(), l)
|
||||
|
||||
case HeaderCompleted:
|
||||
dErr = e.handleCompleted(h.Data(), l)
|
||||
|
||||
default:
|
||||
return ErrHandlerUnknownHeaderType
|
||||
}
|
||||
|
||||
if dErr != nil {
|
||||
l.Debug("Request failed: %s", dErr)
|
||||
|
||||
return dErr
|
||||
}
|
||||
|
||||
l.Debug("Request successful")
|
||||
}
|
||||
}
|
||||
104
application/command/handler_echo_test.go
Normal file
104
application/command/handler_echo_test.go
Normal file
@ -0,0 +1,104 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
func testDummyFetchGen(data []byte) rw.FetchReaderFetcher {
|
||||
current := 0
|
||||
|
||||
return func() ([]byte, error) {
|
||||
if current >= len(data) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
oldCurrent := current
|
||||
current++
|
||||
|
||||
return data[oldCurrent:current], nil
|
||||
}
|
||||
}
|
||||
|
||||
type dummyWriter struct {
|
||||
written []byte
|
||||
}
|
||||
|
||||
func (d *dummyWriter) Write(b []byte) (int, error) {
|
||||
d.written = append(d.written, b...)
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func TestHandlerHandleEcho(t *testing.T) {
|
||||
w := dummyWriter{
|
||||
written: make([]byte, 0, 64),
|
||||
}
|
||||
s := []byte{
|
||||
byte(HeaderControl | 13),
|
||||
HeaderControlEcho,
|
||||
'H', 'E', 'L', 'L', 'O', ' ', 'W', 'O', 'R', 'L', 'D', '1',
|
||||
byte(HeaderControl | 13),
|
||||
HeaderControlEcho,
|
||||
'H', 'E', 'L', 'L', 'O', ' ', 'W', 'O', 'R', 'L', 'D', '2',
|
||||
byte(HeaderControl | HeaderMaxData),
|
||||
HeaderControlEcho,
|
||||
'1', '1', '1', '1', '1', '1', '1', '1', '1', '1',
|
||||
'1', '1', '1', '1', '1', '1', '1', '1', '1', '1',
|
||||
'1', '1', '1', '1', '1', '1', '1', '1', '1', '1',
|
||||
'1', '1', '1', '1', '1', '1', '1', '1', '1', '1',
|
||||
'1', '1', '1', '1', '1', '1', '1', '1', '1', '1',
|
||||
'1', '1', '1', '1', '1', '1', '1', '1', '1', '1',
|
||||
'2', '2',
|
||||
byte(HeaderControl | 13),
|
||||
HeaderControlEcho,
|
||||
'H', 'E', 'L', 'L', 'O', ' ', 'W', 'O', 'R', 'L', 'D', '3',
|
||||
}
|
||||
lock := sync.Mutex{}
|
||||
handler := newHandler(
|
||||
Configuration{},
|
||||
nil,
|
||||
rw.NewFetchReader(testDummyFetchGen(s)),
|
||||
&w,
|
||||
&lock,
|
||||
0,
|
||||
0,
|
||||
log.NewDitch(),
|
||||
)
|
||||
|
||||
hErr := handler.Handle()
|
||||
|
||||
if hErr != nil && hErr != io.EOF {
|
||||
t.Error("Failed to write due to error:", hErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !bytes.Equal(w.written, s) {
|
||||
t.Errorf("Expecting the data to be %d, got %d instead", s, w.written)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
262
application/command/handler_stream_test.go
Normal file
262
application/command/handler_stream_test.go
Normal file
@ -0,0 +1,262 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
func testDummyFetchChainGen(dd <-chan []byte) rw.FetchReaderFetcher {
|
||||
var data []byte
|
||||
var ok bool
|
||||
|
||||
current := 0
|
||||
|
||||
return func() ([]byte, error) {
|
||||
for {
|
||||
if current >= len(data) {
|
||||
data, ok = <-dd
|
||||
|
||||
if !ok {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
current = 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
oldCurrent := current
|
||||
current++
|
||||
|
||||
return data[oldCurrent:current], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type dummyStreamCommand struct {
|
||||
lock sync.Mutex
|
||||
l log.Logger
|
||||
w StreamResponder
|
||||
downWait sync.WaitGroup
|
||||
echoData []byte
|
||||
echoTrans chan []byte
|
||||
}
|
||||
|
||||
func newDummyStreamCommand(
|
||||
l log.Logger,
|
||||
w StreamResponder,
|
||||
cfg Configuration,
|
||||
) FSMMachine {
|
||||
return &dummyStreamCommand{
|
||||
lock: sync.Mutex{},
|
||||
l: l,
|
||||
w: w,
|
||||
downWait: sync.WaitGroup{},
|
||||
echoData: []byte{},
|
||||
echoTrans: make(chan []byte),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dummyStreamCommand) Bootup(
|
||||
r *rw.LimitedReader,
|
||||
b []byte,
|
||||
) (FSMState, FSMError) {
|
||||
d.downWait.Add(1)
|
||||
|
||||
echoTrans := d.echoTrans
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
d.w.Signal(HeaderClose)
|
||||
|
||||
d.downWait.Done()
|
||||
}()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
for {
|
||||
dt, dtOK := <-echoTrans
|
||||
|
||||
if !dtOK {
|
||||
return
|
||||
}
|
||||
|
||||
wErr := d.w.Send(0, []byte{dt[0], dt[1], dt[2], dt[3]}, buf)
|
||||
|
||||
if wErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
commandDataBuf := [5]byte{}
|
||||
|
||||
_, rErr := io.ReadFull(r, commandDataBuf[:])
|
||||
|
||||
if rErr != nil {
|
||||
return nil, ToFSMError(rErr, 11)
|
||||
}
|
||||
|
||||
if !bytes.Equal(commandDataBuf[:], []byte("HELLO")) {
|
||||
panic(fmt.Sprintf("Expecting handsake data to be %s, got %s instead",
|
||||
[]byte("HELLO"), commandDataBuf[:]))
|
||||
}
|
||||
|
||||
if !r.Completed() {
|
||||
panic("R must be Completed")
|
||||
}
|
||||
|
||||
return d.run, NoFSMError()
|
||||
}
|
||||
|
||||
func (d *dummyStreamCommand) run(
|
||||
f *FSM, r *rw.LimitedReader, h StreamHeader, b []byte) error {
|
||||
rLen, rErr := rw.ReadUntilCompleted(r, b[:])
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
d.echoData = make([]byte, rLen)
|
||||
copy(d.echoData, b)
|
||||
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if d.echoTrans != nil {
|
||||
d.echoTrans <- d.echoData
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dummyStreamCommand) Close() error {
|
||||
close(d.echoTrans)
|
||||
|
||||
d.lock.Lock()
|
||||
d.echoTrans = nil
|
||||
d.lock.Unlock()
|
||||
|
||||
d.downWait.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dummyStreamCommand) Release() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandlerHandleStream(t *testing.T) {
|
||||
cmds := Commands{}
|
||||
cmds.Register(0, "name", newDummyStreamCommand, nil)
|
||||
|
||||
readerDataInput := make(chan []byte)
|
||||
|
||||
readerSource := testDummyFetchChainGen(readerDataInput)
|
||||
wBuffer := bytes.NewBuffer(make([]byte, 0, 1024))
|
||||
|
||||
lock := sync.Mutex{}
|
||||
hhd := newHandler(
|
||||
Configuration{},
|
||||
&cmds,
|
||||
rw.NewFetchReader(readerSource),
|
||||
wBuffer,
|
||||
&lock,
|
||||
0,
|
||||
0,
|
||||
log.NewDitch())
|
||||
|
||||
go func() {
|
||||
stInitialHeader := streamInitialHeader{}
|
||||
|
||||
stInitialHeader.set(0, 5, true)
|
||||
|
||||
readerDataInput <- []byte{
|
||||
byte(HeaderStream | 63), stInitialHeader[0], stInitialHeader[1],
|
||||
'H', 'E', 'L', 'L', 'O',
|
||||
}
|
||||
|
||||
stHeader := StreamHeader{}
|
||||
stHeader.Set(0, 5)
|
||||
|
||||
readerDataInput <- []byte{
|
||||
byte(HeaderStream | 63), stHeader[0], stHeader[1],
|
||||
'W', 'O', 'R', 'L', 'D',
|
||||
}
|
||||
|
||||
readerDataInput <- []byte{
|
||||
byte(HeaderStream | 63), stHeader[0], stHeader[1],
|
||||
'0', '1', '2', '3', '4',
|
||||
}
|
||||
|
||||
readerDataInput <- []byte{
|
||||
byte(HeaderClose | 63),
|
||||
}
|
||||
|
||||
close(readerDataInput)
|
||||
}()
|
||||
|
||||
hErr := hhd.Handle()
|
||||
|
||||
if hErr != nil && hErr != io.EOF {
|
||||
t.Error("Failed to handle due to error:", hErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Build the expected header:
|
||||
|
||||
// HeaderStream(63): Success
|
||||
stInitialHeader := streamInitialHeader{}
|
||||
stInitialHeader.set(0, 0, true)
|
||||
|
||||
stHeaders := StreamHeader{}
|
||||
stHeaders.Set(0, 4)
|
||||
|
||||
expected := []byte{
|
||||
// HeaderStream(63): Success
|
||||
byte(HeaderStream | 63), stInitialHeader[0], stInitialHeader[1],
|
||||
|
||||
// HeaderStream(63): Echo 'W', 'O', 'R', 'L' (First 4 bytes of data)
|
||||
byte(HeaderStream | 63), stHeaders[0], stHeaders[1], 'W', 'O', 'R', 'L',
|
||||
|
||||
// HeaderStream(63): Echo '0', '1', '2', '3',
|
||||
byte(HeaderStream | 63), stHeaders[0], stHeaders[1], '0', '1', '2', '3',
|
||||
|
||||
// HeaderClose(63)
|
||||
byte(HeaderClose | 63),
|
||||
|
||||
// HeaderCompleted(63)
|
||||
byte(HeaderCompleted | 63),
|
||||
}
|
||||
|
||||
if !bytes.Equal(wBuffer.Bytes(), expected) {
|
||||
t.Errorf("Expecting received data to be %d, got %d instead",
|
||||
expected, wBuffer.Bytes())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
18
application/command/handler_test.go
Normal file
18
application/command/handler_test.go
Normal file
@ -0,0 +1,18 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
143
application/command/header.go
Normal file
143
application/command/header.go
Normal file
@ -0,0 +1,143 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Header Packet Type
|
||||
type Header byte
|
||||
|
||||
// Packet Types
|
||||
const (
|
||||
// 00------: Control signals
|
||||
// Remaing bits: Data length
|
||||
//
|
||||
// Format:
|
||||
// 0011111 [63 bytes long data] - 63 bytes of control data
|
||||
//
|
||||
HeaderControl Header = 0x00
|
||||
|
||||
// 01------: Bidirectional stream data
|
||||
// Remaining bits: Stream ID
|
||||
// Followed by: Parameter or data
|
||||
//
|
||||
// Format:
|
||||
// 0111111 [Command parameters / data] - Open/use stream 63 to execute
|
||||
// command or transmit data
|
||||
HeaderStream Header = 0x40
|
||||
|
||||
// 10------: Close stream
|
||||
// Remaining bits: Stream ID
|
||||
//
|
||||
// Format:
|
||||
// 1011111 - Close stream 63
|
||||
//
|
||||
// WARNING: The requester MUST NOT send any data to this stream once this
|
||||
// header is sent.
|
||||
//
|
||||
// WARNING: The receiver MUST reply with a Completed header to indicate
|
||||
// the success of the Close action. Until a Completed header is
|
||||
// replied, all data from the sender must be proccessed as normal.
|
||||
HeaderClose Header = 0x80
|
||||
|
||||
// 11------: Stream has been closed/completed in respond to client request
|
||||
// Remaining bits: Stream ID
|
||||
//
|
||||
// Format:
|
||||
// 1111111 - Stream 63 is completed
|
||||
//
|
||||
// WARNING: This header can ONLY be send in respond to a Close header
|
||||
//
|
||||
// WARNING: The sender of this header MUST NOT send any data to the stream
|
||||
// once this header is sent until this stream been re-opened by a
|
||||
// Data header
|
||||
HeaderCompleted Header = 0xc0
|
||||
)
|
||||
|
||||
// Control signal types
|
||||
const (
|
||||
HeaderControlEcho = 0x00
|
||||
HeaderControlPauseStream = 0x01
|
||||
HeaderControlResumeStream = 0x02
|
||||
)
|
||||
|
||||
// Consts
|
||||
const (
|
||||
HeaderMaxData = 0x3f
|
||||
)
|
||||
|
||||
// Cutters
|
||||
const (
|
||||
headerHeaderCutter = 0xc0
|
||||
headerDataCutter = 0x3f
|
||||
)
|
||||
|
||||
// Type get packet type
|
||||
func (p Header) Type() Header {
|
||||
return (p & headerHeaderCutter)
|
||||
}
|
||||
|
||||
// Data returns the data of current Packet header
|
||||
func (p Header) Data() byte {
|
||||
return byte(p & headerDataCutter)
|
||||
}
|
||||
|
||||
// Set set a new value of the Header
|
||||
func (p *Header) Set(data byte) {
|
||||
if data > headerDataCutter {
|
||||
panic("data must not be greater than 0x3f")
|
||||
}
|
||||
|
||||
*p |= (headerDataCutter & Header(data))
|
||||
}
|
||||
|
||||
// Set set a new value of the Header
|
||||
func (p Header) String() string {
|
||||
switch p.Type() {
|
||||
case HeaderControl:
|
||||
return fmt.Sprintf("Control (%d bytes)", p.Data())
|
||||
|
||||
case HeaderStream:
|
||||
return fmt.Sprintf("Stream (%d)", p.Data())
|
||||
|
||||
case HeaderClose:
|
||||
return fmt.Sprintf("Close (Stream %d)", p.Data())
|
||||
|
||||
case HeaderCompleted:
|
||||
return fmt.Sprintf("Completed (Stream %d)", p.Data())
|
||||
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// IsStreamControl returns true when the header is for stream control, false
|
||||
// when otherwise
|
||||
func (p Header) IsStreamControl() bool {
|
||||
switch p {
|
||||
case HeaderStream:
|
||||
fallthrough
|
||||
case HeaderClose:
|
||||
fallthrough
|
||||
case HeaderCompleted:
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
447
application/command/streams.go
Normal file
447
application/command/streams.go
Normal file
@ -0,0 +1,447 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrStreamsInvalidStreamID = errors.New(
|
||||
"stream ID is invalid")
|
||||
|
||||
ErrStreamsStreamOperateInactiveStream = errors.New(
|
||||
"specified stream was inactive for operation")
|
||||
|
||||
ErrStreamsStreamClosingInactiveStream = errors.New(
|
||||
"closing an inactive stream is not allowed")
|
||||
|
||||
ErrStreamsStreamReleasingInactiveStream = errors.New(
|
||||
"releasing an inactive stream is not allowed")
|
||||
)
|
||||
|
||||
// StreamError Stream Error signal
|
||||
type StreamError uint16
|
||||
|
||||
// Error signals
|
||||
const (
|
||||
StreamErrorCommandUndefined StreamError = 0x01
|
||||
StreamErrorCommandFailedToBootup StreamError = 0x02
|
||||
)
|
||||
|
||||
// StreamHeader contains data of the stream header
|
||||
type StreamHeader [2]byte
|
||||
|
||||
// Stream header consts
|
||||
const (
|
||||
StreamHeaderMaxLength = 0x1fff
|
||||
StreamHeaderMaxMarker = 0x07
|
||||
|
||||
streamHeaderLengthFirstByteCutter = 0x1f
|
||||
)
|
||||
|
||||
// Marker returns the header marker data
|
||||
func (s StreamHeader) Marker() byte {
|
||||
return s[0] >> 5
|
||||
}
|
||||
|
||||
// Length returns the data length of the stream
|
||||
func (s StreamHeader) Length() uint16 {
|
||||
r := uint16(0)
|
||||
|
||||
r |= uint16(s[0] & streamHeaderLengthFirstByteCutter)
|
||||
r <<= 8
|
||||
r |= uint16(s[1])
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Set sets the stream header
|
||||
func (s *StreamHeader) Set(marker byte, n uint16) {
|
||||
if marker > StreamHeaderMaxMarker {
|
||||
panic("marker must not be greater than 0x07")
|
||||
}
|
||||
|
||||
if n > StreamHeaderMaxLength {
|
||||
panic("n must not be greater than 0x1fff")
|
||||
}
|
||||
|
||||
s[0] = (marker << 5) | byte((n>>8)&streamHeaderLengthFirstByteCutter)
|
||||
s[1] = byte(n)
|
||||
}
|
||||
|
||||
// streamInitialHeader contains header data of the first stream after stream
|
||||
// reset.
|
||||
// Unlike StreamHeader, streamInitialHeader carries no extra data
|
||||
type streamInitialHeader StreamHeader
|
||||
|
||||
// command returns command ID of the stream
|
||||
func (s streamInitialHeader) command() byte {
|
||||
return s[0] >> 4
|
||||
}
|
||||
|
||||
// length returns the data of the stream header
|
||||
func (s streamInitialHeader) data() uint16 {
|
||||
r := uint16(0)
|
||||
|
||||
r |= uint16(s[0] & 0x07) // 0000 0111
|
||||
r <<= 8
|
||||
r |= uint16(s[1])
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// success returns whether or not the command is representing a success
|
||||
func (s streamInitialHeader) success() bool {
|
||||
return (s[0] & 0x08) != 0
|
||||
}
|
||||
|
||||
// set sets header values
|
||||
func (s *streamInitialHeader) set(commandID byte, data uint16, success bool) {
|
||||
if commandID > 0x0f {
|
||||
panic("Command ID must not greater than 0x0f")
|
||||
}
|
||||
|
||||
if data > 0x07ff {
|
||||
panic("Data must not greater than 0x07ff")
|
||||
}
|
||||
|
||||
dd := data & 0x07ff
|
||||
|
||||
if success {
|
||||
dd |= 0x0800
|
||||
}
|
||||
|
||||
(*s)[0] = 0
|
||||
(*s)[0] |= commandID << 4
|
||||
(*s)[0] |= byte(dd >> 8)
|
||||
(*s)[1] = 0
|
||||
(*s)[1] |= byte(dd)
|
||||
}
|
||||
|
||||
// send sends current stream header as signal
|
||||
func (s *streamInitialHeader) signal(
|
||||
w *handlerSender,
|
||||
hd Header,
|
||||
buf []byte,
|
||||
) error {
|
||||
return w.signal(hd, (*s)[:], buf)
|
||||
}
|
||||
|
||||
// StreamInitialSignalSender sends stream initial signal
|
||||
type StreamInitialSignalSender struct {
|
||||
w *handlerSender
|
||||
hd Header
|
||||
cmdID byte
|
||||
buf []byte
|
||||
}
|
||||
|
||||
// Signal send signal
|
||||
func (s *StreamInitialSignalSender) Signal(
|
||||
errno StreamError, success bool) error {
|
||||
shd := streamInitialHeader{}
|
||||
shd.set(s.cmdID, uint16(errno), success)
|
||||
|
||||
return shd.signal(s.w, s.hd, s.buf)
|
||||
}
|
||||
|
||||
// StreamResponder sends data through stream
|
||||
type StreamResponder struct {
|
||||
w streamHandlerSender
|
||||
h Header
|
||||
}
|
||||
|
||||
// newStreamResponder creates a new StreamResponder
|
||||
func newStreamResponder(w streamHandlerSender, h Header) StreamResponder {
|
||||
return StreamResponder{
|
||||
w: w,
|
||||
h: h,
|
||||
}
|
||||
}
|
||||
|
||||
func (w StreamResponder) write(mk byte, b []byte, buf []byte) (int, error) {
|
||||
bufLen := len(buf)
|
||||
bLen := len(b)
|
||||
|
||||
if bLen > bufLen {
|
||||
bLen = bufLen
|
||||
}
|
||||
|
||||
if bLen > StreamHeaderMaxLength {
|
||||
bLen = StreamHeaderMaxLength
|
||||
}
|
||||
|
||||
sHeaderStream := StreamHeader{}
|
||||
sHeaderStream.Set(mk, uint16(bLen))
|
||||
|
||||
toWrite := copy(buf[3:], b)
|
||||
buf[0] = byte(w.h)
|
||||
buf[1] = sHeaderStream[0]
|
||||
buf[2] = sHeaderStream[1]
|
||||
|
||||
_, wErr := w.w.Write(buf[:toWrite+3])
|
||||
|
||||
if wErr != nil {
|
||||
return 0, wErr
|
||||
}
|
||||
|
||||
return len(b), wErr
|
||||
}
|
||||
|
||||
// HeaderSize returns the size of header
|
||||
func (w StreamResponder) HeaderSize() int {
|
||||
return 3
|
||||
}
|
||||
|
||||
// Send sends data. Data will be automatically segmentated if it's too long to
|
||||
// fit into one data package or buffer space
|
||||
func (w StreamResponder) Send(marker byte, data []byte, buf []byte) error {
|
||||
if len(buf) <= w.HeaderSize() {
|
||||
panic("The length of data buffer must be greater than 3")
|
||||
}
|
||||
|
||||
dataLen := len(data)
|
||||
start := 0
|
||||
|
||||
for {
|
||||
wLen, wErr := w.write(marker, data[start:], buf)
|
||||
|
||||
start += wLen
|
||||
|
||||
if wErr != nil {
|
||||
return wErr
|
||||
}
|
||||
|
||||
if start < dataLen {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SendManual sends the data without automatical segmentation. It will construct
|
||||
// the data package directly using the given `data` buffer, that is, the first
|
||||
// n bytes of the given `data` will be used to setup headers. It is the caller's
|
||||
//
|
||||
// responsibility to leave n bytes of space so no meaningful data will be over
|
||||
//
|
||||
// written. The number n can be acquired by calling .HeaderSize() method.
|
||||
func (w StreamResponder) SendManual(marker byte, data []byte) error {
|
||||
dataLen := len(data)
|
||||
|
||||
if dataLen < w.HeaderSize() {
|
||||
panic("The length of data buffer must be greater than the " +
|
||||
"w.HeaderSize()")
|
||||
}
|
||||
|
||||
if dataLen > StreamHeaderMaxLength {
|
||||
panic("Data length must not greater than StreamHeaderMaxLength")
|
||||
}
|
||||
|
||||
sHeaderStream := StreamHeader{}
|
||||
sHeaderStream.Set(marker, uint16(dataLen-w.HeaderSize()))
|
||||
|
||||
data[0] = byte(w.h)
|
||||
data[1] = sHeaderStream[0]
|
||||
data[2] = sHeaderStream[1]
|
||||
|
||||
_, wErr := w.w.Write(data)
|
||||
|
||||
return wErr
|
||||
}
|
||||
|
||||
// Signal sends a signal
|
||||
func (w StreamResponder) Signal(signal Header) error {
|
||||
if !signal.IsStreamControl() {
|
||||
panic("Only stream control signal is allowed")
|
||||
}
|
||||
|
||||
sHeader := signal
|
||||
sHeader.Set(w.h.Data())
|
||||
|
||||
_, wErr := w.w.Write([]byte{byte(sHeader)})
|
||||
|
||||
return wErr
|
||||
}
|
||||
|
||||
type stream struct {
|
||||
f FSM
|
||||
closed bool
|
||||
}
|
||||
|
||||
type streams [HeaderMaxData + 1]stream
|
||||
|
||||
func newStream() stream {
|
||||
return stream{
|
||||
f: emptyFSM(),
|
||||
closed: false,
|
||||
}
|
||||
}
|
||||
|
||||
func newStreams() streams {
|
||||
s := streams{}
|
||||
|
||||
for i := range s {
|
||||
s[i] = newStream()
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *streams) get(id byte) (*stream, error) {
|
||||
if id > HeaderMaxData {
|
||||
return nil, ErrStreamsInvalidStreamID
|
||||
}
|
||||
|
||||
return &(*c)[id], nil
|
||||
}
|
||||
|
||||
func (c *streams) shutdown() {
|
||||
cc := *c
|
||||
|
||||
for i := range cc {
|
||||
if !cc[i].running() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !cc[i].closed {
|
||||
cc[i].close()
|
||||
}
|
||||
|
||||
cc[i].release()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *stream) running() bool {
|
||||
return c.f.running()
|
||||
}
|
||||
|
||||
func (c *stream) reinit(
|
||||
h Header,
|
||||
r *rw.FetchReader,
|
||||
w streamHandlerSender,
|
||||
l log.Logger,
|
||||
cc *Commands,
|
||||
cfg Configuration,
|
||||
b []byte,
|
||||
) error {
|
||||
hd := streamInitialHeader{}
|
||||
|
||||
_, rErr := io.ReadFull(r, hd[:])
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
l = l.Context("Command (%d)", hd.command())
|
||||
|
||||
ccc, cccErr := cc.Run(
|
||||
hd.command(), l, newStreamResponder(w, h), cfg)
|
||||
|
||||
if cccErr != nil {
|
||||
hd.set(0, uint16(StreamErrorCommandUndefined), false)
|
||||
hd.signal(w.handlerSender, h, b)
|
||||
|
||||
l.Warning("Trying to execute an unknown command %d", hd.command())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
signaller := StreamInitialSignalSender{
|
||||
w: w.handlerSender,
|
||||
hd: h,
|
||||
cmdID: hd.command(),
|
||||
buf: b,
|
||||
}
|
||||
|
||||
rr := rw.NewLimitedReader(r, int(hd.data()))
|
||||
defer rr.Ditch(b)
|
||||
|
||||
bootErr := ccc.bootup(&rr, b)
|
||||
|
||||
if !bootErr.Succeed() {
|
||||
l.Warning("Unable to start command %d due to error: %s",
|
||||
hd.command(), bootErr.Error())
|
||||
|
||||
signaller.Signal(bootErr.code, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
c.f = ccc
|
||||
c.closed = false
|
||||
|
||||
sErr := signaller.Signal(bootErr.code, true)
|
||||
|
||||
if sErr != nil {
|
||||
return sErr
|
||||
}
|
||||
|
||||
l.Debug("Started")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *stream) tick(
|
||||
h Header,
|
||||
r *rw.FetchReader,
|
||||
b []byte,
|
||||
) error {
|
||||
if !c.f.running() {
|
||||
return ErrStreamsStreamOperateInactiveStream
|
||||
}
|
||||
|
||||
hd := StreamHeader{}
|
||||
|
||||
_, rErr := io.ReadFull(r, hd[:])
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
rr := rw.NewLimitedReader(r, int(hd.Length()))
|
||||
defer rr.Ditch(b)
|
||||
|
||||
return c.f.tick(&rr, hd, b)
|
||||
}
|
||||
|
||||
func (c *stream) close() error {
|
||||
if !c.f.running() {
|
||||
return ErrStreamsStreamClosingInactiveStream
|
||||
}
|
||||
|
||||
// Set a marker so streams.shutdown won't call it. Stream can call it
|
||||
// however they want, though that may cause error that disconnects.
|
||||
c.closed = true
|
||||
|
||||
return c.f.close()
|
||||
}
|
||||
|
||||
func (c *stream) release() error {
|
||||
if !c.f.running() {
|
||||
return ErrStreamsStreamReleasingInactiveStream
|
||||
}
|
||||
|
||||
return c.f.release()
|
||||
}
|
||||
97
application/command/streams_test.go
Normal file
97
application/command/streams_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStreamInitialHeader(t *testing.T) {
|
||||
hd := streamInitialHeader{}
|
||||
|
||||
hd.set(15, 128, true)
|
||||
|
||||
if hd.command() != 15 {
|
||||
t.Errorf("Expecting command to be %d, got %d instead",
|
||||
15, hd.command())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if hd.data() != 128 {
|
||||
t.Errorf("Expecting data to be %d, got %d instead", 128, hd.data())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if hd.success() != true {
|
||||
t.Errorf("Expecting success to be %v, got %v instead",
|
||||
true, hd.success())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hd.set(0, 2047, false)
|
||||
|
||||
if hd.command() != 0 {
|
||||
t.Errorf("Expecting command to be %d, got %d instead",
|
||||
0, hd.command())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if hd.data() != 2047 {
|
||||
t.Errorf("Expecting data to be %d, got %d instead", 2047, hd.data())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if hd.success() != false {
|
||||
t.Errorf("Expecting success to be %v, got %v instead",
|
||||
false, hd.success())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamHeader(t *testing.T) {
|
||||
s := StreamHeader{}
|
||||
|
||||
s.Set(StreamHeaderMaxMarker, StreamHeaderMaxLength)
|
||||
|
||||
if s.Marker() != StreamHeaderMaxMarker {
|
||||
t.Errorf("Expecting the marker to be %d, got %d instead",
|
||||
StreamHeaderMaxMarker, s.Marker())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if s.Length() != StreamHeaderMaxLength {
|
||||
t.Errorf("Expecting the length to be %d, got %d instead",
|
||||
StreamHeaderMaxLength, s.Length())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if s[0] != s[1] || s[0] != 0xff {
|
||||
t.Errorf("Expecting the header to be 255, 255, got %d, %d instead",
|
||||
s[0], s[1])
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
275
application/commands/address.go
Normal file
275
application/commands/address.go
Normal file
@ -0,0 +1,275 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrAddressParseBufferTooSmallForHeader = errors.New(
|
||||
"buffer space was too small to parse the address header")
|
||||
|
||||
ErrAddressParseBufferTooSmallForIPv4 = errors.New(
|
||||
"buffer space was too small to parse the IPv4 address")
|
||||
|
||||
ErrAddressParseBufferTooSmallForIPv6 = errors.New(
|
||||
"buffer space was too small to parse the IPv6 address")
|
||||
|
||||
ErrAddressParseBufferTooSmallForHostName = errors.New(
|
||||
"buffer space was too small to parse the hostname address")
|
||||
|
||||
ErrAddressMarshalBufferTooSmall = errors.New(
|
||||
"buffer space was too small to marshal the address")
|
||||
|
||||
ErrAddressInvalidAddressType = errors.New(
|
||||
"invalid address type")
|
||||
)
|
||||
|
||||
// AddressType Type of the address
|
||||
type AddressType byte
|
||||
|
||||
// Address types
|
||||
const (
|
||||
LoopbackAddr AddressType = 0x00
|
||||
IPv4Addr AddressType = 0x01
|
||||
IPv6Addr AddressType = 0x02
|
||||
HostNameAddr AddressType = 0x03
|
||||
)
|
||||
|
||||
// Address data
|
||||
type Address struct {
|
||||
port uint16
|
||||
kind AddressType
|
||||
data []byte
|
||||
}
|
||||
|
||||
// ParseAddress parses the reader and return an Address
|
||||
//
|
||||
// Address data format:
|
||||
// +-------------+--------------+---------------+
|
||||
// | 2 bytes | 1 byte | n bytes |
|
||||
// +-------------+--------------+---------------+
|
||||
// | Port number | Address type | Address data |
|
||||
// +-------------+--------------+---------------+
|
||||
//
|
||||
// Address types:
|
||||
// - LoopbackAddr: 00 Localhost, don't carry Address data
|
||||
// - IPv4Addr: 01 IPv4 Address, carries 4 bytes of Address data
|
||||
// - IPv6Addr: 10 IPv6 Address, carries 16 bytes Address data
|
||||
// - HostnameAddr: 11 Host name string, length of Address data is indicated
|
||||
// by the remainer of the byte (11-- ----). maxlen = 63
|
||||
func ParseAddress(reader rw.ReaderFunc, buf []byte) (Address, error) {
|
||||
if len(buf) < 3 {
|
||||
return Address{}, ErrAddressParseBufferTooSmallForHeader
|
||||
}
|
||||
|
||||
_, rErr := rw.ReadFull(reader, buf[:3])
|
||||
|
||||
if rErr != nil {
|
||||
return Address{}, rErr
|
||||
}
|
||||
|
||||
portNum := uint16(0)
|
||||
portNum |= uint16(buf[0])
|
||||
portNum <<= 8
|
||||
portNum |= uint16(buf[1])
|
||||
|
||||
addrType := AddressType(buf[2] >> 6)
|
||||
|
||||
var addrData []byte
|
||||
|
||||
switch addrType {
|
||||
case LoopbackAddr:
|
||||
// Do nothing
|
||||
|
||||
case IPv4Addr:
|
||||
if len(buf) < 4 {
|
||||
return Address{}, ErrAddressParseBufferTooSmallForIPv4
|
||||
}
|
||||
|
||||
_, rErr := rw.ReadFull(reader, buf[:4])
|
||||
|
||||
if rErr != nil {
|
||||
return Address{}, rErr
|
||||
}
|
||||
|
||||
addrData = buf[:4]
|
||||
|
||||
case IPv6Addr:
|
||||
if len(buf) < 16 {
|
||||
return Address{}, ErrAddressParseBufferTooSmallForIPv6
|
||||
}
|
||||
|
||||
_, rErr := rw.ReadFull(reader, buf[:16])
|
||||
|
||||
if rErr != nil {
|
||||
return Address{}, rErr
|
||||
}
|
||||
|
||||
addrData = buf[:16]
|
||||
|
||||
case HostNameAddr:
|
||||
addrDataLen := int(0x3f & buf[2])
|
||||
|
||||
if len(buf) < addrDataLen {
|
||||
return Address{}, ErrAddressParseBufferTooSmallForHostName
|
||||
}
|
||||
|
||||
_, rErr := rw.ReadFull(reader, buf[:addrDataLen])
|
||||
|
||||
if rErr != nil {
|
||||
return Address{}, rErr
|
||||
}
|
||||
|
||||
addrData = buf[:addrDataLen]
|
||||
|
||||
default:
|
||||
return Address{}, ErrAddressInvalidAddressType
|
||||
}
|
||||
|
||||
return Address{
|
||||
port: portNum,
|
||||
kind: addrType,
|
||||
data: addrData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewAddress creates a new Address
|
||||
func NewAddress(addrType AddressType, data []byte, port uint16) Address {
|
||||
return Address{
|
||||
port: port,
|
||||
kind: addrType,
|
||||
data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// Type returns the type of the address
|
||||
func (a Address) Type() AddressType {
|
||||
return a.kind
|
||||
}
|
||||
|
||||
// Data returns the address data
|
||||
func (a Address) Data() []byte {
|
||||
return a.data
|
||||
}
|
||||
|
||||
// Port returns port number
|
||||
func (a Address) Port() uint16 {
|
||||
return a.port
|
||||
}
|
||||
|
||||
// Marshal writes address data to the given b
|
||||
func (a Address) Marshal(b []byte) (int, error) {
|
||||
bLen := len(b)
|
||||
|
||||
switch a.Type() {
|
||||
case LoopbackAddr:
|
||||
if bLen < 3 {
|
||||
return 0, ErrAddressMarshalBufferTooSmall
|
||||
}
|
||||
|
||||
b[0] = byte(a.port >> 8)
|
||||
b[1] = byte(a.port)
|
||||
b[2] = byte(LoopbackAddr << 6)
|
||||
|
||||
return 3, nil
|
||||
|
||||
case IPv4Addr:
|
||||
if bLen < 7 {
|
||||
return 0, ErrAddressMarshalBufferTooSmall
|
||||
}
|
||||
|
||||
b[0] = byte(a.port >> 8)
|
||||
b[1] = byte(a.port)
|
||||
b[2] = byte(IPv4Addr << 6)
|
||||
|
||||
copy(b[3:], a.data)
|
||||
|
||||
return 7, nil
|
||||
|
||||
case IPv6Addr:
|
||||
if bLen < 19 {
|
||||
return 0, ErrAddressMarshalBufferTooSmall
|
||||
}
|
||||
|
||||
b[0] = byte(a.port >> 8)
|
||||
b[1] = byte(a.port)
|
||||
b[2] = byte(IPv6Addr << 6)
|
||||
|
||||
copy(b[3:], a.data)
|
||||
|
||||
return 19, nil
|
||||
|
||||
case HostNameAddr:
|
||||
hLen := len(a.data)
|
||||
|
||||
if hLen > 0x3f {
|
||||
panic("Host name cannot longer than 0x3f")
|
||||
}
|
||||
|
||||
if bLen < hLen+3 {
|
||||
return 0, ErrAddressMarshalBufferTooSmall
|
||||
}
|
||||
|
||||
b[0] = byte(a.port >> 8)
|
||||
b[1] = byte(a.port)
|
||||
b[2] = byte(HostNameAddr << 6)
|
||||
b[2] |= byte(hLen)
|
||||
|
||||
copy(b[3:], a.data)
|
||||
|
||||
return hLen + 3, nil
|
||||
|
||||
default:
|
||||
return 0, ErrAddressInvalidAddressType
|
||||
}
|
||||
}
|
||||
|
||||
// String return the Address as string
|
||||
func (a Address) String() string {
|
||||
switch a.Type() {
|
||||
case LoopbackAddr:
|
||||
return net.JoinHostPort(
|
||||
"localhost",
|
||||
strconv.FormatUint(uint64(a.Port()), 10))
|
||||
|
||||
case IPv4Addr:
|
||||
return net.JoinHostPort(
|
||||
net.IPv4(a.data[0], a.data[1], a.data[2], a.data[3]).String(),
|
||||
strconv.FormatUint(uint64(a.Port()), 10))
|
||||
|
||||
case IPv6Addr:
|
||||
return net.JoinHostPort(
|
||||
net.IP(a.data[:net.IPv6len]).String(),
|
||||
strconv.FormatUint(uint64(a.Port()), 10))
|
||||
|
||||
case HostNameAddr:
|
||||
return net.JoinHostPort(
|
||||
string(a.data),
|
||||
strconv.FormatUint(uint64(a.Port()), 10))
|
||||
|
||||
default:
|
||||
panic("Unknown Address type")
|
||||
}
|
||||
}
|
||||
138
application/commands/address_test.go
Normal file
138
application/commands/address_test.go
Normal file
@ -0,0 +1,138 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testParseAddress(
|
||||
t *testing.T,
|
||||
input []byte,
|
||||
buf []byte,
|
||||
expectedType AddressType,
|
||||
expectedData []byte,
|
||||
expectedPort uint16,
|
||||
expectedHostPortString string,
|
||||
) {
|
||||
source := bytes.NewBuffer(input)
|
||||
addr, addrErr := ParseAddress(source.Read, buf)
|
||||
|
||||
if addrErr != nil {
|
||||
t.Error("Failed to parse due to error:", addrErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if addr.Type() != expectedType {
|
||||
t.Errorf("Expecting the Type to be %d, got %d instead",
|
||||
expectedType, addr.Type())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !bytes.Equal(addr.Data(), expectedData) {
|
||||
t.Errorf("Expecting the Data to be %d, got %d instead",
|
||||
expectedData, addr.Data())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if addr.Port() != expectedPort {
|
||||
t.Errorf("Expecting the Port to be %d, got %d instead",
|
||||
expectedPort, addr.Port())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if addr.String() != expectedHostPortString {
|
||||
t.Errorf("Expecting the Host Port string to be \"%s\", "+
|
||||
"got \"%s\" instead",
|
||||
expectedHostPortString, addr.String())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
output := make([]byte, len(input))
|
||||
mLen, mErr := addr.Marshal(output)
|
||||
|
||||
if mErr != nil {
|
||||
t.Error("Failed to marshal due to error:", mErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !bytes.Equal(output[:mLen], input) {
|
||||
t.Errorf("Expecting marshaled result to be %d, got %d instead",
|
||||
input, output[:mLen])
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddress(t *testing.T) {
|
||||
testParseAddress(
|
||||
t, []byte{0x04, 0x1e, 0x00}, make([]byte, 3), LoopbackAddr, nil, 1054,
|
||||
"localhost:1054")
|
||||
|
||||
testParseAddress(
|
||||
t,
|
||||
[]byte{
|
||||
0x04, 0x1e, 0x40,
|
||||
0x7f, 0x00, 0x00, 0x01,
|
||||
},
|
||||
make([]byte, 4), IPv4Addr, []byte{0x7f, 0x00, 0x00, 0x01}, 1054,
|
||||
"127.0.0.1:1054")
|
||||
|
||||
testParseAddress(
|
||||
t,
|
||||
[]byte{
|
||||
0x04, 0x1e, 0x80,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x7f, 0x00, 0x00, 0x01,
|
||||
},
|
||||
make([]byte, 16), IPv6Addr, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x7f, 0x00, 0x00, 0x01}, 1054,
|
||||
"[::7f00:1]:1054")
|
||||
|
||||
testParseAddress(
|
||||
t,
|
||||
[]byte{
|
||||
0x04, 0x1e, 0xff,
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'1', '2', '3',
|
||||
},
|
||||
make([]byte, 63), HostNameAddr, []byte{
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
|
||||
'1', '2', '3',
|
||||
}, 1054,
|
||||
strings.Repeat("ABCDEFGHIJ", 6)+"123:1054")
|
||||
}
|
||||
30
application/commands/commands.go
Normal file
30
application/commands/commands.go
Normal file
@ -0,0 +1,30 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/nirui/sshwifty/application/command"
|
||||
)
|
||||
|
||||
// New creates a new commands group
|
||||
func New() command.Commands {
|
||||
return command.Commands{
|
||||
command.Register("Telnet", newTelnet, parseTelnetConfig),
|
||||
command.Register("SSH", newSSH, parseSSHConfig),
|
||||
}
|
||||
}
|
||||
120
application/commands/integer.go
Normal file
120
application/commands/integer.go
Normal file
@ -0,0 +1,120 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrIntegerMarshalNotEnoughBuffer = errors.New(
|
||||
"not enough buffer to marshal the integer")
|
||||
|
||||
ErrIntegerMarshalTooLarge = errors.New(
|
||||
"integer cannot be marshalled, because the vaule was too large")
|
||||
)
|
||||
|
||||
// Integer is a 16bit unsigned integer data
|
||||
//
|
||||
// Format:
|
||||
// +-------------------------------------+--------------+
|
||||
// | 1 bit | 7 bits |
|
||||
// +-------------------------------------+--------------+
|
||||
// | 1 when current byte is the end byte | Integer data |
|
||||
// +-------------------------------------+--------------+
|
||||
//
|
||||
// Example:
|
||||
// - 00000000 00000000: 0
|
||||
// - 01111111: 127
|
||||
// - 11111111 01000000: 255
|
||||
type Integer uint16
|
||||
|
||||
const (
|
||||
integerHasNextBit = 0x80
|
||||
integerValueCutter = 0x7f
|
||||
)
|
||||
|
||||
// Consts
|
||||
const (
|
||||
MaxInteger = 0x3fff
|
||||
MaxIntegerBytes = 2
|
||||
)
|
||||
|
||||
// ByteSize returns how many bytes current integer will be encoded into
|
||||
func (i *Integer) ByteSize() int {
|
||||
if *i > integerValueCutter {
|
||||
return 2
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// Int returns a int of current Integer
|
||||
func (i *Integer) Int() int {
|
||||
return int(*i)
|
||||
}
|
||||
|
||||
// Marshal build serialized data of the integer
|
||||
func (i *Integer) Marshal(b []byte) (int, error) {
|
||||
bLen := len(b)
|
||||
|
||||
if *i > MaxInteger {
|
||||
return 0, ErrIntegerMarshalTooLarge
|
||||
}
|
||||
|
||||
if bLen < i.ByteSize() {
|
||||
return 0, ErrIntegerMarshalNotEnoughBuffer
|
||||
}
|
||||
|
||||
if *i <= integerValueCutter {
|
||||
b[0] = byte(*i & integerValueCutter)
|
||||
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
b[0] = byte((*i >> 7) | integerHasNextBit)
|
||||
b[1] = byte(*i & integerValueCutter)
|
||||
|
||||
return 2, nil
|
||||
}
|
||||
|
||||
// Unmarshal read data and parse the integer
|
||||
func (i *Integer) Unmarshal(reader rw.ReaderFunc) error {
|
||||
buf := [1]byte{}
|
||||
|
||||
for j := 0; j < MaxIntegerBytes; j++ {
|
||||
_, rErr := rw.ReadFull(reader, buf[:])
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
*i |= Integer(buf[0] & integerValueCutter)
|
||||
|
||||
if integerHasNextBit&buf[0] == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
*i <<= 7
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
124
application/commands/integer_test.go
Normal file
124
application/commands/integer_test.go
Normal file
@ -0,0 +1,124 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInteger(t *testing.T) {
|
||||
ii := Integer(0x3fff)
|
||||
result := Integer(0)
|
||||
buf := make([]byte, 2)
|
||||
|
||||
mLen, mErr := ii.Marshal(buf)
|
||||
|
||||
if mErr != nil {
|
||||
t.Error("Failed to marshal:", mErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
mData := bytes.NewBuffer(buf[:mLen])
|
||||
mErr = result.Unmarshal(mData.Read)
|
||||
|
||||
if mErr != nil {
|
||||
t.Error("Failed to unmarshal:", mErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if result != ii {
|
||||
t.Errorf("Expecting result to be %d, got %d instead", ii, result)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegerSingleByte1(t *testing.T) {
|
||||
ii := Integer(102)
|
||||
result := Integer(0)
|
||||
buf := make([]byte, 2)
|
||||
|
||||
mLen, mErr := ii.Marshal(buf)
|
||||
|
||||
if mErr != nil {
|
||||
t.Error("Failed to marshal:", mErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if mLen != 1 {
|
||||
t.Errorf("Expecting the Integer to be marshalled into %d bytes, got "+
|
||||
"%d instead", 1, mLen)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
mData := bytes.NewBuffer(buf[:mLen])
|
||||
mErr = result.Unmarshal(mData.Read)
|
||||
|
||||
if mErr != nil {
|
||||
t.Error("Failed to unmarshal:", mErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if result != ii {
|
||||
t.Errorf("Expecting result to be %d, got %d instead", ii, result)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegerSingleByte2(t *testing.T) {
|
||||
ii := Integer(127)
|
||||
result := Integer(0)
|
||||
buf := make([]byte, 2)
|
||||
|
||||
mLen, mErr := ii.Marshal(buf)
|
||||
|
||||
if mErr != nil {
|
||||
t.Error("Failed to marshal:", mErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if mLen != 1 {
|
||||
t.Errorf("Expecting the Integer to be marshalled into %d bytes, got "+
|
||||
"%d instead", 1, mLen)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
mData := bytes.NewBuffer(buf[:mLen])
|
||||
mErr = result.Unmarshal(mData.Read)
|
||||
|
||||
if mErr != nil {
|
||||
t.Error("Failed to unmarshal:", mErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if result != ii {
|
||||
t.Errorf("Expecting result to be %d, got %d instead", ii, result)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
783
application/commands/ssh.go
Normal file
783
application/commands/ssh.go
Normal file
@ -0,0 +1,783 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/nirui/sshwifty/application/command"
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/network"
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Server -> client signal Consts
|
||||
const (
|
||||
SSHServerRemoteStdOut = 0x00
|
||||
SSHServerRemoteStdErr = 0x01
|
||||
SSHServerConnectFailed = 0x02
|
||||
SSHServerConnectSucceed = 0x03
|
||||
SSHServerConnectVerifyFingerprint = 0x04
|
||||
SSHServerConnectRequestCredential = 0x05
|
||||
)
|
||||
|
||||
// Client -> server signal consts
|
||||
const (
|
||||
SSHClientStdIn = 0x00
|
||||
SSHClientResize = 0x01
|
||||
SSHClientRespondFingerprint = 0x02
|
||||
SSHClientRespondCredential = 0x03
|
||||
)
|
||||
|
||||
const (
|
||||
sshCredentialMaxSize = 4096
|
||||
)
|
||||
|
||||
// Error codes
|
||||
const (
|
||||
SSHRequestErrorBadUserName = command.StreamError(0x01)
|
||||
SSHRequestErrorBadRemoteAddress = command.StreamError(0x02)
|
||||
SSHRequestErrorBadAuthMethod = command.StreamError(0x03)
|
||||
)
|
||||
|
||||
// Auth methods
|
||||
const (
|
||||
SSHAuthMethodNone byte = 0x00
|
||||
SSHAuthMethodPassphrase byte = 0x01
|
||||
SSHAuthMethodPrivateKey byte = 0x02
|
||||
)
|
||||
|
||||
type sshAuthMethodBuilder func(b []byte) []ssh.AuthMethod
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrSSHAuthCancelled = errors.New(
|
||||
"authentication has been cancelled")
|
||||
|
||||
ErrSSHInvalidAuthMethod = errors.New(
|
||||
"invalid auth method")
|
||||
|
||||
ErrSSHInvalidAddress = errors.New(
|
||||
"invalid address")
|
||||
|
||||
ErrSSHRemoteFingerprintVerificationCancelled = errors.New(
|
||||
"server Fingerprint verification process has been cancelled")
|
||||
|
||||
ErrSSHRemoteFingerprintRefused = errors.New(
|
||||
"server Fingerprint has been refused")
|
||||
|
||||
ErrSSHRemoteConnUnavailable = errors.New(
|
||||
"remote SSH connection is unavailable")
|
||||
|
||||
ErrSSHUnexpectedFingerprintVerificationRespond = errors.New(
|
||||
"unexpected fingerprint verification respond")
|
||||
|
||||
ErrSSHUnexpectedCredentialDataRespond = errors.New(
|
||||
"unexpected credential data respond")
|
||||
|
||||
ErrSSHCredentialDataTooLarge = errors.New(
|
||||
"credential was too large")
|
||||
|
||||
ErrSSHUnknownClientSignal = errors.New(
|
||||
"unknown client signal")
|
||||
)
|
||||
|
||||
var (
|
||||
sshEmptyTime = time.Time{}
|
||||
)
|
||||
|
||||
const (
|
||||
sshDefaultPortString = "22"
|
||||
)
|
||||
|
||||
type sshRemoteConnWrapper struct {
|
||||
net.Conn
|
||||
|
||||
writerConn network.WriteTimeoutConn
|
||||
requestTimeoutRetry func(s *sshRemoteConnWrapper) bool
|
||||
}
|
||||
|
||||
func (s *sshRemoteConnWrapper) Read(b []byte) (int, error) {
|
||||
for {
|
||||
rLen, rErr := s.Conn.Read(b)
|
||||
|
||||
if rErr == nil {
|
||||
return rLen, nil
|
||||
}
|
||||
|
||||
netErr, isNetErr := rErr.(net.Error)
|
||||
|
||||
if !isNetErr || !netErr.Timeout() || !s.requestTimeoutRetry(s) {
|
||||
return rLen, rErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sshRemoteConnWrapper) Write(b []byte) (int, error) {
|
||||
return s.writerConn.Write(b)
|
||||
}
|
||||
|
||||
type sshRemoteConn struct {
|
||||
writer io.Writer
|
||||
closer func() error
|
||||
session *ssh.Session
|
||||
}
|
||||
|
||||
func (s sshRemoteConn) isValid() bool {
|
||||
return s.writer != nil && s.closer != nil && s.session != nil
|
||||
}
|
||||
|
||||
type sshClient struct {
|
||||
w command.StreamResponder
|
||||
l log.Logger
|
||||
cfg command.Configuration
|
||||
remoteCloseWait sync.WaitGroup
|
||||
remoteReadTimeoutRetry bool
|
||||
remoteReadForceRetryNextTimeout bool
|
||||
remoteReadTimeoutRetryLock sync.Mutex
|
||||
credentialReceive chan []byte
|
||||
credentialProcessed bool
|
||||
credentialReceiveClosed bool
|
||||
fingerprintVerifyResultReceive chan bool
|
||||
fingerprintProcessed bool
|
||||
fingerprintVerifyResultReceiveClosed bool
|
||||
remoteConnReceive chan sshRemoteConn
|
||||
remoteConn sshRemoteConn
|
||||
}
|
||||
|
||||
func newSSH(
|
||||
l log.Logger,
|
||||
w command.StreamResponder,
|
||||
cfg command.Configuration,
|
||||
) command.FSMMachine {
|
||||
return &sshClient{
|
||||
w: w,
|
||||
l: l,
|
||||
cfg: cfg,
|
||||
remoteCloseWait: sync.WaitGroup{},
|
||||
remoteReadTimeoutRetry: false,
|
||||
remoteReadForceRetryNextTimeout: false,
|
||||
remoteReadTimeoutRetryLock: sync.Mutex{},
|
||||
credentialReceive: make(chan []byte, 1),
|
||||
credentialProcessed: false,
|
||||
credentialReceiveClosed: false,
|
||||
fingerprintVerifyResultReceive: make(chan bool, 1),
|
||||
fingerprintProcessed: false,
|
||||
fingerprintVerifyResultReceiveClosed: false,
|
||||
remoteConnReceive: make(chan sshRemoteConn, 1),
|
||||
remoteConn: sshRemoteConn{},
|
||||
}
|
||||
}
|
||||
|
||||
func parseSSHConfig(p configuration.Preset) (configuration.Preset, error) {
|
||||
oldHost := p.Host
|
||||
|
||||
_, _, sErr := net.SplitHostPort(p.Host)
|
||||
|
||||
if sErr != nil {
|
||||
p.Host = net.JoinHostPort(p.Host, sshDefaultPortString)
|
||||
}
|
||||
|
||||
if len(p.Host) <= 0 {
|
||||
p.Host = oldHost
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (d *sshClient) Bootup(
|
||||
r *rw.LimitedReader,
|
||||
b []byte,
|
||||
) (command.FSMState, command.FSMError) {
|
||||
// User name
|
||||
userName, userNameErr := ParseString(r.Read, b)
|
||||
|
||||
if userNameErr != nil {
|
||||
return nil, command.ToFSMError(
|
||||
userNameErr, SSHRequestErrorBadUserName)
|
||||
}
|
||||
|
||||
userNameStr := string(userName.Data())
|
||||
|
||||
// Address
|
||||
addr, addrErr := ParseAddress(r.Read, b)
|
||||
|
||||
if addrErr != nil {
|
||||
return nil, command.ToFSMError(
|
||||
addrErr, SSHRequestErrorBadRemoteAddress)
|
||||
}
|
||||
|
||||
addrStr := addr.String()
|
||||
|
||||
if len(addrStr) <= 0 {
|
||||
return nil, command.ToFSMError(
|
||||
ErrSSHInvalidAddress, SSHRequestErrorBadRemoteAddress)
|
||||
}
|
||||
|
||||
// Auth method
|
||||
rData, rErr := rw.FetchOneByte(r.Fetch)
|
||||
|
||||
if rErr != nil {
|
||||
return nil, command.ToFSMError(
|
||||
rErr, SSHRequestErrorBadAuthMethod)
|
||||
}
|
||||
|
||||
authMethodBuilder, authMethodBuilderErr := d.buildAuthMethod(rData[0])
|
||||
|
||||
if authMethodBuilderErr != nil {
|
||||
return nil, command.ToFSMError(
|
||||
authMethodBuilderErr, SSHRequestErrorBadAuthMethod)
|
||||
}
|
||||
|
||||
d.remoteCloseWait.Add(1)
|
||||
go d.remote(userNameStr, addrStr, authMethodBuilder)
|
||||
|
||||
return d.local, command.NoFSMError()
|
||||
}
|
||||
|
||||
func (d *sshClient) buildAuthMethod(
|
||||
methodType byte) (sshAuthMethodBuilder, error) {
|
||||
switch methodType {
|
||||
case SSHAuthMethodNone:
|
||||
return func(b []byte) []ssh.AuthMethod {
|
||||
return nil
|
||||
}, nil
|
||||
|
||||
case SSHAuthMethodPassphrase:
|
||||
return func(b []byte) []ssh.AuthMethod {
|
||||
return []ssh.AuthMethod{
|
||||
ssh.PasswordCallback(func() (string, error) {
|
||||
d.enableRemoteReadTimeoutRetry()
|
||||
defer d.disableRemoteReadTimeoutRetry()
|
||||
|
||||
wErr := d.w.SendManual(
|
||||
SSHServerConnectRequestCredential,
|
||||
b[d.w.HeaderSize():],
|
||||
)
|
||||
|
||||
if wErr != nil {
|
||||
return "", wErr
|
||||
}
|
||||
|
||||
passphraseBytes, passphraseReceived := <-d.credentialReceive
|
||||
|
||||
if !passphraseReceived {
|
||||
return "", ErrSSHAuthCancelled
|
||||
}
|
||||
|
||||
return string(passphraseBytes), nil
|
||||
}),
|
||||
}
|
||||
}, nil
|
||||
|
||||
case SSHAuthMethodPrivateKey:
|
||||
return func(b []byte) []ssh.AuthMethod {
|
||||
return []ssh.AuthMethod{
|
||||
ssh.PublicKeysCallback(func() ([]ssh.Signer, error) {
|
||||
d.enableRemoteReadTimeoutRetry()
|
||||
defer d.disableRemoteReadTimeoutRetry()
|
||||
|
||||
wErr := d.w.SendManual(
|
||||
SSHServerConnectRequestCredential,
|
||||
b[d.w.HeaderSize():],
|
||||
)
|
||||
|
||||
if wErr != nil {
|
||||
return nil, wErr
|
||||
}
|
||||
|
||||
privateKeyBytes, privateKeyReceived := <-d.credentialReceive
|
||||
|
||||
if !privateKeyReceived {
|
||||
return nil, ErrSSHAuthCancelled
|
||||
}
|
||||
|
||||
signer, signerErr := ssh.ParsePrivateKey(privateKeyBytes)
|
||||
|
||||
if signerErr != nil {
|
||||
return nil, signerErr
|
||||
}
|
||||
|
||||
return []ssh.Signer{signer}, signerErr
|
||||
}),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, ErrSSHInvalidAuthMethod
|
||||
}
|
||||
|
||||
func (d *sshClient) confirmRemoteFingerprint(
|
||||
hostname string,
|
||||
remote net.Addr,
|
||||
key ssh.PublicKey,
|
||||
buf []byte,
|
||||
) error {
|
||||
d.enableRemoteReadTimeoutRetry()
|
||||
defer d.disableRemoteReadTimeoutRetry()
|
||||
|
||||
fgp := ssh.FingerprintSHA256(key)
|
||||
fgpLen := copy(buf[d.w.HeaderSize():], fgp)
|
||||
|
||||
wErr := d.w.SendManual(
|
||||
SSHServerConnectVerifyFingerprint,
|
||||
buf[:d.w.HeaderSize()+fgpLen],
|
||||
)
|
||||
|
||||
if wErr != nil {
|
||||
return wErr
|
||||
}
|
||||
|
||||
confirmed, confirmOK := <-d.fingerprintVerifyResultReceive
|
||||
|
||||
if !confirmOK {
|
||||
return ErrSSHRemoteFingerprintVerificationCancelled
|
||||
}
|
||||
|
||||
if !confirmed {
|
||||
return ErrSSHRemoteFingerprintRefused
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *sshClient) enableRemoteReadTimeoutRetry() {
|
||||
d.remoteReadTimeoutRetryLock.Lock()
|
||||
defer d.remoteReadTimeoutRetryLock.Unlock()
|
||||
|
||||
d.remoteReadTimeoutRetry = true
|
||||
}
|
||||
|
||||
func (d *sshClient) disableRemoteReadTimeoutRetry() {
|
||||
d.remoteReadTimeoutRetryLock.Lock()
|
||||
defer d.remoteReadTimeoutRetryLock.Unlock()
|
||||
|
||||
d.remoteReadTimeoutRetry = false
|
||||
d.remoteReadForceRetryNextTimeout = true
|
||||
}
|
||||
|
||||
func (d *sshClient) dialRemote(
|
||||
networkName,
|
||||
addr string,
|
||||
config *ssh.ClientConfig) (*ssh.Client, func(), error) {
|
||||
conn, err := d.cfg.Dial(networkName, addr, config.Timeout)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
sshConn := &sshRemoteConnWrapper{
|
||||
Conn: conn,
|
||||
writerConn: network.NewWriteTimeoutConn(conn, d.cfg.DialTimeout),
|
||||
requestTimeoutRetry: func(s *sshRemoteConnWrapper) bool {
|
||||
d.remoteReadTimeoutRetryLock.Lock()
|
||||
defer d.remoteReadTimeoutRetryLock.Unlock()
|
||||
|
||||
if !d.remoteReadTimeoutRetry {
|
||||
if !d.remoteReadForceRetryNextTimeout {
|
||||
return false
|
||||
}
|
||||
|
||||
d.remoteReadForceRetryNextTimeout = false
|
||||
}
|
||||
|
||||
s.SetReadDeadline(time.Now().Add(config.Timeout))
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// Set timeout for writer, otherwise the Timeout writer will never
|
||||
// be triggered
|
||||
sshConn.SetWriteDeadline(time.Now().Add(d.cfg.DialTimeout))
|
||||
sshConn.SetReadDeadline(time.Now().Add(config.Timeout))
|
||||
|
||||
c, chans, reqs, err := ssh.NewClientConn(sshConn, addr, config)
|
||||
|
||||
if err != nil {
|
||||
sshConn.Close()
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return ssh.NewClient(c, chans, reqs), func() {
|
||||
d.remoteReadTimeoutRetryLock.Lock()
|
||||
defer d.remoteReadTimeoutRetryLock.Unlock()
|
||||
|
||||
d.remoteReadTimeoutRetry = false
|
||||
d.remoteReadForceRetryNextTimeout = true
|
||||
|
||||
sshConn.SetReadDeadline(sshEmptyTime)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *sshClient) remote(
|
||||
user string, address string, authMethodBuilder sshAuthMethodBuilder) {
|
||||
defer func() {
|
||||
d.w.Signal(command.HeaderClose)
|
||||
|
||||
close(d.remoteConnReceive)
|
||||
d.remoteCloseWait.Done()
|
||||
}()
|
||||
|
||||
buf := [4096]byte{}
|
||||
|
||||
conn, clearConnInitialDeadline, dErr :=
|
||||
d.dialRemote("tcp", address, &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: authMethodBuilder(buf[:]),
|
||||
HostKeyCallback: func(h string, r net.Addr, k ssh.PublicKey) error {
|
||||
return d.confirmRemoteFingerprint(h, r, k, buf[:])
|
||||
},
|
||||
Timeout: d.cfg.DialTimeout,
|
||||
})
|
||||
|
||||
if dErr != nil {
|
||||
errLen := copy(buf[d.w.HeaderSize():], dErr.Error()) + d.w.HeaderSize()
|
||||
d.w.SendManual(SSHServerConnectFailed, buf[:errLen])
|
||||
|
||||
d.l.Debug("Unable to connect to remote machine: %s", dErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
session, sErr := conn.NewSession()
|
||||
|
||||
if sErr != nil {
|
||||
errLen := copy(buf[d.w.HeaderSize():], sErr.Error()) + d.w.HeaderSize()
|
||||
d.w.SendManual(SSHServerConnectFailed, buf[:errLen])
|
||||
|
||||
d.l.Debug("Unable open new session on remote machine: %s", sErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer session.Close()
|
||||
|
||||
in, inErr := session.StdinPipe()
|
||||
|
||||
if inErr != nil {
|
||||
errLen := copy(buf[d.w.HeaderSize():], inErr.Error()) + d.w.HeaderSize()
|
||||
d.w.SendManual(SSHServerConnectFailed, buf[:errLen])
|
||||
|
||||
d.l.Debug("Unable export Stdin pipe: %s", inErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
out, outErr := session.StdoutPipe()
|
||||
|
||||
if outErr != nil {
|
||||
errLen := copy(buf[d.w.HeaderSize():], outErr.Error()) +
|
||||
d.w.HeaderSize()
|
||||
d.w.SendManual(SSHServerConnectFailed, buf[:errLen])
|
||||
|
||||
d.l.Debug("Unable export Stdout pipe: %s", outErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
errOut, outErrErr := session.StderrPipe()
|
||||
|
||||
if outErrErr != nil {
|
||||
errLen := copy(buf[d.w.HeaderSize():], outErrErr.Error()) +
|
||||
d.w.HeaderSize()
|
||||
d.w.SendManual(SSHServerConnectFailed, buf[:errLen])
|
||||
|
||||
d.l.Debug("Unable export Stderr pipe: %s", outErrErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sErr = session.RequestPty("xterm", 80, 40, ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
})
|
||||
|
||||
if sErr != nil {
|
||||
errLen := copy(buf[d.w.HeaderSize():], sErr.Error()) + d.w.HeaderSize()
|
||||
d.w.SendManual(SSHServerConnectFailed, buf[:errLen])
|
||||
|
||||
d.l.Debug("Unable request PTY: %s", sErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sErr = session.Shell()
|
||||
|
||||
if sErr != nil {
|
||||
errLen := copy(buf[d.w.HeaderSize():], sErr.Error()) + d.w.HeaderSize()
|
||||
d.w.SendManual(SSHServerConnectFailed, buf[:errLen])
|
||||
|
||||
d.l.Debug("Unable to start Shell: %s", sErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer session.Wait()
|
||||
|
||||
clearConnInitialDeadline()
|
||||
|
||||
d.remoteConnReceive <- sshRemoteConn{
|
||||
writer: in,
|
||||
closer: func() error {
|
||||
session.Close()
|
||||
|
||||
return conn.Close()
|
||||
},
|
||||
session: session,
|
||||
}
|
||||
|
||||
wErr := d.w.SendManual(
|
||||
SSHServerConnectSucceed, buf[:d.w.HeaderSize()])
|
||||
|
||||
if wErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
d.l.Debug("Serving")
|
||||
|
||||
d.remoteCloseWait.Add(1)
|
||||
|
||||
go func() {
|
||||
defer d.remoteCloseWait.Done()
|
||||
|
||||
errOutBuf := [4096]byte{}
|
||||
|
||||
for {
|
||||
rLen, rErr := errOut.Read(errOutBuf[d.w.HeaderSize():])
|
||||
|
||||
if rErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rErr = d.w.SendManual(
|
||||
SSHServerRemoteStdErr, errOutBuf[:d.w.HeaderSize()+rLen])
|
||||
|
||||
if rErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
rLen, rErr := out.Read(buf[d.w.HeaderSize():])
|
||||
|
||||
if rErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rErr = d.w.SendManual(
|
||||
SSHServerRemoteStdOut, buf[:d.w.HeaderSize()+rLen])
|
||||
|
||||
if rErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *sshClient) getRemote() (sshRemoteConn, error) {
|
||||
if d.remoteConn.isValid() {
|
||||
return d.remoteConn, nil
|
||||
}
|
||||
|
||||
remoteConn, remoteConnFetched := <-d.remoteConnReceive
|
||||
|
||||
if !remoteConnFetched {
|
||||
return sshRemoteConn{}, ErrSSHRemoteConnUnavailable
|
||||
}
|
||||
|
||||
d.remoteConn = remoteConn
|
||||
|
||||
return d.remoteConn, nil
|
||||
}
|
||||
|
||||
func (d *sshClient) local(
|
||||
f *command.FSM,
|
||||
r *rw.LimitedReader,
|
||||
h command.StreamHeader,
|
||||
b []byte,
|
||||
) error {
|
||||
switch h.Marker() {
|
||||
case SSHClientStdIn:
|
||||
remote, remoteErr := d.getRemote()
|
||||
|
||||
if remoteErr != nil {
|
||||
return remoteErr
|
||||
}
|
||||
|
||||
for !r.Completed() {
|
||||
rData, rErr := r.Buffered()
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
_, wErr := remote.writer.Write(rData)
|
||||
|
||||
if wErr != nil {
|
||||
remote.closer()
|
||||
|
||||
d.l.Debug("Failed to write data to remote: %s", wErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case SSHClientResize:
|
||||
remote, remoteErr := d.getRemote()
|
||||
|
||||
if remoteErr != nil {
|
||||
return remoteErr
|
||||
}
|
||||
|
||||
_, rErr := io.ReadFull(r, b[:4])
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
rows := int(b[0])
|
||||
rows <<= 8
|
||||
rows |= int(b[1])
|
||||
|
||||
cols := int(b[2])
|
||||
cols <<= 8
|
||||
cols |= int(b[3])
|
||||
|
||||
// It's ok for it to fail
|
||||
wcErr := remote.session.WindowChange(rows, cols)
|
||||
|
||||
if wcErr != nil {
|
||||
d.l.Debug("Failed to resize to %d, %d: %s", rows, cols, wcErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case SSHClientRespondFingerprint:
|
||||
if d.fingerprintProcessed {
|
||||
return ErrSSHUnexpectedFingerprintVerificationRespond
|
||||
}
|
||||
|
||||
d.fingerprintProcessed = true
|
||||
|
||||
rData, rErr := rw.FetchOneByte(r.Fetch)
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
comfirmed := rData[0] == 0
|
||||
|
||||
if !comfirmed {
|
||||
d.fingerprintVerifyResultReceive <- false
|
||||
|
||||
remote, remoteErr := d.getRemote()
|
||||
|
||||
if remoteErr == nil {
|
||||
remote.closer()
|
||||
}
|
||||
} else {
|
||||
d.fingerprintVerifyResultReceive <- true
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case SSHClientRespondCredential:
|
||||
if d.credentialProcessed {
|
||||
return ErrSSHUnexpectedCredentialDataRespond
|
||||
}
|
||||
|
||||
d.credentialProcessed = true
|
||||
|
||||
sshCredentialBufSize := 0
|
||||
|
||||
if r.Remains() > sshCredentialMaxSize {
|
||||
sshCredentialBufSize = sshCredentialMaxSize
|
||||
} else {
|
||||
sshCredentialBufSize = r.Remains()
|
||||
}
|
||||
|
||||
credentialDataBuf := make([]byte, 0, sshCredentialBufSize)
|
||||
totalCredentialRead := 0
|
||||
|
||||
for !r.Completed() {
|
||||
rData, rErr := r.Buffered()
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
totalCredentialRead += len(rData)
|
||||
|
||||
if totalCredentialRead > sshCredentialBufSize {
|
||||
return ErrSSHCredentialDataTooLarge
|
||||
}
|
||||
|
||||
credentialDataBuf = append(credentialDataBuf, rData...)
|
||||
}
|
||||
|
||||
d.credentialReceive <- credentialDataBuf
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
return ErrSSHUnknownClientSignal
|
||||
}
|
||||
}
|
||||
|
||||
func (d *sshClient) Close() error {
|
||||
d.credentialProcessed = true
|
||||
d.fingerprintProcessed = true
|
||||
|
||||
if !d.credentialReceiveClosed {
|
||||
close(d.credentialReceive)
|
||||
|
||||
d.credentialReceiveClosed = true
|
||||
}
|
||||
|
||||
if !d.fingerprintVerifyResultReceiveClosed {
|
||||
close(d.fingerprintVerifyResultReceive)
|
||||
|
||||
d.fingerprintVerifyResultReceiveClosed = true
|
||||
}
|
||||
|
||||
remote, remoteErr := d.getRemote()
|
||||
|
||||
if remoteErr == nil {
|
||||
remote.closer()
|
||||
}
|
||||
|
||||
d.remoteCloseWait.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *sshClient) Release() error {
|
||||
return nil
|
||||
}
|
||||
103
application/commands/string.go
Normal file
103
application/commands/string.go
Normal file
@ -0,0 +1,103 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrStringParseBufferTooSmall = errors.New(
|
||||
"not enough buffer space to parse given string")
|
||||
|
||||
ErrStringMarshalBufferTooSmall = errors.New(
|
||||
"not enough buffer space to marshal given string")
|
||||
)
|
||||
|
||||
// String data
|
||||
type String struct {
|
||||
len Integer
|
||||
data []byte
|
||||
}
|
||||
|
||||
// ParseString build the String according to readed data
|
||||
func ParseString(reader rw.ReaderFunc, b []byte) (String, error) {
|
||||
lenData := Integer(0)
|
||||
|
||||
mErr := lenData.Unmarshal(reader)
|
||||
|
||||
if mErr != nil {
|
||||
return String{}, mErr
|
||||
}
|
||||
|
||||
bLen := len(b)
|
||||
|
||||
if bLen < lenData.Int() {
|
||||
return String{}, ErrStringParseBufferTooSmall
|
||||
}
|
||||
|
||||
_, rErr := rw.ReadFull(reader, b[:lenData])
|
||||
|
||||
if rErr != nil {
|
||||
return String{}, rErr
|
||||
}
|
||||
|
||||
return String{
|
||||
len: lenData,
|
||||
data: b[:lenData],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewString create a new String
|
||||
func NewString(d []byte) String {
|
||||
dLen := len(d)
|
||||
|
||||
if dLen > MaxInteger {
|
||||
panic("Data was too long for a String")
|
||||
}
|
||||
|
||||
return String{
|
||||
len: Integer(dLen),
|
||||
data: d,
|
||||
}
|
||||
}
|
||||
|
||||
// Data returns the data of the string
|
||||
func (s String) Data() []byte {
|
||||
return s.data
|
||||
}
|
||||
|
||||
// Marshal the string to give buffer
|
||||
func (s String) Marshal(b []byte) (int, error) {
|
||||
bLen := len(b)
|
||||
|
||||
if bLen < s.len.ByteSize()+len(s.data) {
|
||||
return 0, ErrStringMarshalBufferTooSmall
|
||||
}
|
||||
|
||||
mLen, mErr := s.len.Marshal(b)
|
||||
|
||||
if mErr != nil {
|
||||
return 0, mErr
|
||||
}
|
||||
|
||||
return copy(b[mLen:], s.data) + mLen, nil
|
||||
}
|
||||
83
application/commands/string_test.go
Normal file
83
application/commands/string_test.go
Normal file
@ -0,0 +1,83 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testString(t *testing.T, str []byte) {
|
||||
ss := NewString(str)
|
||||
mm := make([]byte, len(str)+2)
|
||||
|
||||
mLen, mErr := ss.Marshal(mm)
|
||||
|
||||
if mErr != nil {
|
||||
t.Error("Failed to marshal:", mErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, mLen)
|
||||
source := bytes.NewBuffer(mm[:mLen])
|
||||
result, rErr := ParseString(source.Read, buf)
|
||||
|
||||
if rErr != nil {
|
||||
t.Error("Failed to parse:", rErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !bytes.Equal(result.Data(), ss.Data()) {
|
||||
t.Errorf("Expecting the data to be %d, got %d instead",
|
||||
ss.Data(), result.Data())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
testString(t, []byte{
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
})
|
||||
|
||||
testString(t, []byte{
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'i',
|
||||
})
|
||||
}
|
||||
229
application/commands/telnet.go
Normal file
229
application/commands/telnet.go
Normal file
@ -0,0 +1,229 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/command"
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/network"
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrTelnetUnableToReceiveRemoteConn = errors.New(
|
||||
"unable to acquire remote connection handle")
|
||||
)
|
||||
|
||||
// Error codes
|
||||
const (
|
||||
TelnetRequestErrorBadRemoteAddress = command.StreamError(0x01)
|
||||
)
|
||||
|
||||
const (
|
||||
telnetDefaultPortString = "23"
|
||||
)
|
||||
|
||||
// Server signal codes
|
||||
const (
|
||||
TelnetServerRemoteBand = 0x00
|
||||
TelnetServerDialFailed = 0x01
|
||||
TelnetServerDialConnected = 0x02
|
||||
)
|
||||
|
||||
type telnetClient struct {
|
||||
l log.Logger
|
||||
w command.StreamResponder
|
||||
cfg command.Configuration
|
||||
remoteChan chan net.Conn
|
||||
remoteConn net.Conn
|
||||
closeWait sync.WaitGroup
|
||||
}
|
||||
|
||||
func newTelnet(
|
||||
l log.Logger,
|
||||
w command.StreamResponder,
|
||||
cfg command.Configuration,
|
||||
) command.FSMMachine {
|
||||
return &telnetClient{
|
||||
l: l,
|
||||
w: w,
|
||||
cfg: cfg,
|
||||
remoteChan: make(chan net.Conn, 1),
|
||||
remoteConn: nil,
|
||||
closeWait: sync.WaitGroup{},
|
||||
}
|
||||
}
|
||||
|
||||
func parseTelnetConfig(p configuration.Preset) (configuration.Preset, error) {
|
||||
oldHost := p.Host
|
||||
|
||||
_, _, sErr := net.SplitHostPort(p.Host)
|
||||
|
||||
if sErr != nil {
|
||||
p.Host = net.JoinHostPort(p.Host, telnetDefaultPortString)
|
||||
}
|
||||
|
||||
if len(p.Host) <= 0 {
|
||||
p.Host = oldHost
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (d *telnetClient) Bootup(
|
||||
r *rw.LimitedReader,
|
||||
b []byte) (command.FSMState, command.FSMError) {
|
||||
addr, addrErr := ParseAddress(r.Read, b)
|
||||
|
||||
if addrErr != nil {
|
||||
return nil, command.ToFSMError(
|
||||
addrErr, TelnetRequestErrorBadRemoteAddress)
|
||||
}
|
||||
|
||||
d.closeWait.Add(1)
|
||||
go d.remote(addr.String())
|
||||
|
||||
return d.client, command.NoFSMError()
|
||||
}
|
||||
|
||||
func (d *telnetClient) remote(addr string) {
|
||||
defer func() {
|
||||
d.w.Signal(command.HeaderClose)
|
||||
|
||||
close(d.remoteChan)
|
||||
d.closeWait.Done()
|
||||
}()
|
||||
|
||||
buf := [4096]byte{}
|
||||
|
||||
clientConn, clientConnErr := d.cfg.Dial("tcp", addr, d.cfg.DialTimeout)
|
||||
|
||||
if clientConnErr != nil {
|
||||
errLen := copy(
|
||||
buf[d.w.HeaderSize():], clientConnErr.Error()) + d.w.HeaderSize()
|
||||
d.w.SendManual(TelnetServerDialFailed, buf[:errLen])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer clientConn.Close()
|
||||
|
||||
clientConnErr = d.w.SendManual(
|
||||
TelnetServerDialConnected,
|
||||
buf[:d.w.HeaderSize()],
|
||||
)
|
||||
|
||||
if clientConnErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Set timeout for writer, otherwise the Timeout writer will never
|
||||
// be triggered
|
||||
clientConn.SetWriteDeadline(time.Now().Add(d.cfg.DialTimeout))
|
||||
timeoutClientConn := network.NewWriteTimeoutConn(
|
||||
clientConn, d.cfg.DialTimeout)
|
||||
|
||||
d.remoteChan <- &timeoutClientConn
|
||||
|
||||
for {
|
||||
rLen, rErr := clientConn.Read(buf[d.w.HeaderSize():])
|
||||
|
||||
if rErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
wErr := d.w.SendManual(
|
||||
TelnetServerRemoteBand, buf[:rLen+d.w.HeaderSize()])
|
||||
|
||||
if wErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *telnetClient) getRemote() (net.Conn, error) {
|
||||
if d.remoteConn != nil {
|
||||
return d.remoteConn, nil
|
||||
}
|
||||
|
||||
remoteConn, ok := <-d.remoteChan
|
||||
|
||||
if !ok {
|
||||
return nil, ErrTelnetUnableToReceiveRemoteConn
|
||||
}
|
||||
|
||||
d.remoteConn = remoteConn
|
||||
|
||||
return d.remoteConn, nil
|
||||
}
|
||||
|
||||
func (d *telnetClient) client(
|
||||
f *command.FSM,
|
||||
r *rw.LimitedReader,
|
||||
h command.StreamHeader,
|
||||
b []byte,
|
||||
) error {
|
||||
remoteConn, remoteConnErr := d.getRemote()
|
||||
|
||||
if remoteConnErr != nil {
|
||||
return remoteConnErr
|
||||
}
|
||||
|
||||
// All Telnet requests are in-band, so we just directly send them all
|
||||
// to the server
|
||||
for !r.Completed() {
|
||||
rBuf, rErr := r.Buffered()
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
|
||||
_, wErr := remoteConn.Write(rBuf)
|
||||
|
||||
if wErr != nil {
|
||||
remoteConn.Close()
|
||||
|
||||
d.l.Debug("Failed to write data to remote: %s", wErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *telnetClient) Close() error {
|
||||
remoteConn, remoteConnErr := d.getRemote()
|
||||
|
||||
if remoteConnErr == nil {
|
||||
remoteConn.Close()
|
||||
}
|
||||
|
||||
d.closeWait.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *telnetClient) Release() error {
|
||||
return nil
|
||||
}
|
||||
26
application/configuration/common.go
Normal file
26
application/configuration/common.go
Normal file
@ -0,0 +1,26 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
func durationAtLeast(current, min int) int {
|
||||
if current > min {
|
||||
return current
|
||||
}
|
||||
|
||||
return min
|
||||
}
|
||||
253
application/configuration/config.go
Normal file
253
application/configuration/config.go
Normal file
@ -0,0 +1,253 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/network"
|
||||
)
|
||||
|
||||
// Server contains configuration of a HTTP server
|
||||
type Server struct {
|
||||
ListenInterface string
|
||||
ListenPort uint16
|
||||
InitialTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
HeartbeatTimeout time.Duration
|
||||
ReadDelay time.Duration
|
||||
WriteDelay time.Duration
|
||||
TLSCertificateFile string
|
||||
TLSCertificateKeyFile string
|
||||
ServerMessage string
|
||||
}
|
||||
|
||||
func (s Server) defaultListenInterface() string {
|
||||
if len(s.ListenInterface) > 0 {
|
||||
return s.ListenInterface
|
||||
}
|
||||
|
||||
return net.IPv4(127, 0, 0, 1).String()
|
||||
}
|
||||
|
||||
func (s Server) defaultListenPort() uint16 {
|
||||
if s.ListenPort > 0 {
|
||||
return s.ListenPort
|
||||
}
|
||||
|
||||
return 80
|
||||
}
|
||||
|
||||
func (s Server) maxDur(cur, def time.Duration) time.Duration {
|
||||
if cur > def {
|
||||
return cur
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
func (s Server) minDur(cur, def time.Duration) time.Duration {
|
||||
if cur < def {
|
||||
return cur
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
// WithDefault build the configuration and fill the blank with default values
|
||||
func (s Server) WithDefault() Server {
|
||||
initialTimeout := s.maxDur(s.InitialTimeout, 1*time.Second)
|
||||
|
||||
readTimeout := s.maxDur(initialTimeout, 3*time.Second)
|
||||
readTimeout = s.maxDur(s.ReadTimeout, readTimeout)
|
||||
|
||||
maxHeartBeatTimeout := time.Duration(float64(readTimeout) * 0.8)
|
||||
heartBeatTimeout := s.minDur(s.HeartbeatTimeout, maxHeartBeatTimeout)
|
||||
|
||||
if heartBeatTimeout <= 0 {
|
||||
heartBeatTimeout = maxHeartBeatTimeout
|
||||
}
|
||||
|
||||
return Server{
|
||||
ListenInterface: s.defaultListenInterface(),
|
||||
ListenPort: s.defaultListenPort(),
|
||||
InitialTimeout: initialTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: s.maxDur(s.WriteTimeout, 3*time.Second),
|
||||
HeartbeatTimeout: heartBeatTimeout,
|
||||
ReadDelay: s.ReadDelay,
|
||||
WriteDelay: s.WriteDelay,
|
||||
TLSCertificateFile: s.TLSCertificateFile,
|
||||
TLSCertificateKeyFile: s.TLSCertificateKeyFile,
|
||||
ServerMessage: s.ServerMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// IsTLS returns whether or not TLS should be used
|
||||
func (s Server) IsTLS() bool {
|
||||
return len(s.TLSCertificateFile) > 0 && len(s.TLSCertificateKeyFile) > 0
|
||||
}
|
||||
|
||||
// Verify verifies current configuration
|
||||
func (s Server) Verify() error {
|
||||
if net.ParseIP(s.ListenInterface) == nil {
|
||||
return fmt.Errorf("invalid IP address \"%s\"", s.ListenInterface)
|
||||
}
|
||||
|
||||
if (len(s.TLSCertificateFile) > 0 && len(s.TLSCertificateKeyFile) <= 0) ||
|
||||
(len(s.TLSCertificateFile) <= 0 && len(s.TLSCertificateKeyFile) > 0) {
|
||||
return errors.New("TLSCertificateFile and TLSCertificateKeyFile must " +
|
||||
"both be specified in order to enable TLS")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Meta contains data of a Key -> Value map which can be use to store
|
||||
// dynamically structured configuration options
|
||||
type Meta map[string]String
|
||||
|
||||
// Concretize returns an concretized Meta as a `map[string]string`
|
||||
func (m Meta) Concretize() (map[string]string, error) {
|
||||
mm := make(map[string]string, len(m))
|
||||
|
||||
for k, v := range m {
|
||||
result, err := v.Parse()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse Meta \"%s\": %s", k, err)
|
||||
}
|
||||
|
||||
mm[k] = result
|
||||
}
|
||||
|
||||
return mm, 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
|
||||
DialTimeout time.Duration
|
||||
Socks5 string
|
||||
Socks5User string
|
||||
Socks5Password string
|
||||
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
|
||||
Presets []Preset
|
||||
OnlyAllowPresetRemotes bool
|
||||
}
|
||||
|
||||
// Verify verifies current setting
|
||||
func (c Configuration) Verify() error {
|
||||
if len(c.Servers) <= 0 {
|
||||
return errors.New("must specify at least one server")
|
||||
}
|
||||
|
||||
for i, c := range c.Servers {
|
||||
vErr := c.Verify()
|
||||
|
||||
if vErr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid setting for server %d: %s", i, vErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dialer builds a Dialer
|
||||
func (c Configuration) Dialer() network.Dial {
|
||||
dialTimeout := c.DialTimeout
|
||||
|
||||
if dialTimeout < 3 {
|
||||
dialTimeout = 3
|
||||
}
|
||||
|
||||
dialer := network.TCPDial()
|
||||
|
||||
if len(c.Socks5) > 0 {
|
||||
sDial, sDialErr := network.BuildSocks5Dial(
|
||||
c.Socks5, c.Socks5User, c.Socks5Password)
|
||||
|
||||
if sDialErr != nil {
|
||||
panic("Unable to build Socks5 Dialer: " + sDialErr.Error())
|
||||
}
|
||||
|
||||
dialer = sDial
|
||||
}
|
||||
|
||||
if c.OnlyAllowPresetRemotes {
|
||||
accessList := make(network.AllowedHosts, len(c.Presets))
|
||||
|
||||
for _, k := range c.Presets {
|
||||
if len(k.Host) <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
accessList[k.Host] = struct{}{}
|
||||
}
|
||||
|
||||
dialer = network.AccessControlDial(accessList, dialer)
|
||||
}
|
||||
|
||||
return dialer
|
||||
}
|
||||
|
||||
// Common returns common settings
|
||||
func (c Configuration) Common() Common {
|
||||
return Common{
|
||||
HostName: c.HostName,
|
||||
SharedKey: c.SharedKey,
|
||||
Dialer: c.Dialer(),
|
||||
DialTimeout: c.DialTimeout,
|
||||
Presets: c.Presets,
|
||||
OnlyAllowPresetRemotes: c.OnlyAllowPresetRemotes,
|
||||
}
|
||||
}
|
||||
|
||||
// DecideDialTimeout will return a reasonable timeout for dialing
|
||||
func (c Common) DecideDialTimeout(max time.Duration) time.Duration {
|
||||
if c.DialTimeout > max {
|
||||
return max
|
||||
}
|
||||
|
||||
return c.DialTimeout
|
||||
}
|
||||
28
application/configuration/loader.go
Normal file
28
application/configuration/loader.go
Normal file
@ -0,0 +1,28 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
// PresetReloader reloads preset
|
||||
type PresetReloader func(p Preset) (Preset, error)
|
||||
|
||||
// Loader Configuration loader
|
||||
type Loader func(log log.Logger) (name string, cfg Configuration, err error)
|
||||
34
application/configuration/loader_direct.go
Normal file
34
application/configuration/loader_direct.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
const (
|
||||
directTypeName = "Direct"
|
||||
)
|
||||
|
||||
// Direct creates a loader that return raw configuration data directly.
|
||||
// Good for integration.
|
||||
func Direct(cfg Configuration) Loader {
|
||||
return func(log log.Logger) (string, Configuration, error) {
|
||||
return directTypeName, cfg, nil
|
||||
}
|
||||
}
|
||||
139
application/configuration/loader_enviro.go
Normal file
139
application/configuration/loader_enviro.go
Normal file
@ -0,0 +1,139 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
const (
|
||||
enviroTypeName = "Environment Variable"
|
||||
)
|
||||
|
||||
func parseEnv(name string) string {
|
||||
v := os.Getenv(name)
|
||||
|
||||
if !strings.HasPrefix(v, "SSHWIFTY_ENV_RENAMED:") {
|
||||
return v
|
||||
}
|
||||
|
||||
return os.Getenv(v[21:])
|
||||
}
|
||||
|
||||
// Enviro creates an environment variable based configuration loader
|
||||
func Enviro() Loader {
|
||||
return func(log log.Logger) (string, Configuration, error) {
|
||||
log.Info("Loading configuration from environment variables ...")
|
||||
|
||||
dialTimeout, _ := strconv.ParseUint(
|
||||
parseEnv("SSHWIFTY_DIALTIMEOUT"), 10, 32)
|
||||
|
||||
cfg, cfgErr := fileCfgCommon{
|
||||
HostName: parseEnv("SSHWIFTY_HOSTNAME"),
|
||||
SharedKey: parseEnv("SSHWIFTY_SHAREDKEY"),
|
||||
DialTimeout: int(dialTimeout),
|
||||
Socks5: parseEnv("SSHWIFTY_SOCKS5"),
|
||||
Socks5User: parseEnv("SSHWIFTY_SOCKS5_USER"),
|
||||
Socks5Password: parseEnv("SSHWIFTY_SOCKS5_PASSWORD"),
|
||||
Servers: nil,
|
||||
Presets: nil,
|
||||
OnlyAllowPresetRemotes: len(
|
||||
parseEnv("SSHWIFTY_ONLYALLOWPRESETREMOTES")) > 0,
|
||||
}.build()
|
||||
|
||||
if cfgErr != nil {
|
||||
return enviroTypeName, Configuration{}, fmt.Errorf(
|
||||
"failed to build the configuration: %s", cfgErr)
|
||||
}
|
||||
|
||||
listenIface := parseEnv("SSHWIFTY_LISTENINTERFACE")
|
||||
|
||||
listenPort, _ := strconv.ParseUint(
|
||||
parseEnv("SSHWIFTY_LISTENPORT"), 10, 16)
|
||||
|
||||
initialTimeout, _ := strconv.ParseUint(
|
||||
parseEnv("SSHWIFTY_INITIALTIMEOUT"), 10, 32)
|
||||
|
||||
readTimeout, _ := strconv.ParseUint(
|
||||
parseEnv("SSHWIFTY_READTIMEOUT"), 10, 32)
|
||||
|
||||
writeTimeout, _ := strconv.ParseUint(
|
||||
parseEnv("SSHWIFTY_WRITETIMEOUT"), 10, 32)
|
||||
|
||||
heartbeatTimeout, _ := strconv.ParseUint(
|
||||
parseEnv("SSHWIFTY_HEARTBEATTIMEOUT"), 10, 32)
|
||||
|
||||
readDelay, _ := strconv.ParseUint(
|
||||
parseEnv("SSHWIFTY_READDELAY"), 10, 32)
|
||||
|
||||
writeDelay, _ := strconv.ParseUint(
|
||||
parseEnv("SSHWIFTY_WRITEELAY"), 10, 32)
|
||||
|
||||
cfgSer := fileCfgServer{
|
||||
ListenInterface: listenIface,
|
||||
ListenPort: uint16(listenPort),
|
||||
InitialTimeout: int(initialTimeout),
|
||||
ReadTimeout: int(readTimeout),
|
||||
WriteTimeout: int(writeTimeout),
|
||||
HeartbeatTimeout: int(heartbeatTimeout),
|
||||
ReadDelay: int(readDelay),
|
||||
WriteDelay: int(writeDelay),
|
||||
TLSCertificateFile: parseEnv("SSHWIFTY_TLSCERTIFICATEFILE"),
|
||||
TLSCertificateKeyFile: parseEnv("SSHWIFTY_TLSCERTIFICATEKEYFILE"),
|
||||
ServerMessage: parseEnv("SSHWIFTY_SERVERMESSAGE"),
|
||||
}
|
||||
|
||||
presets := make(fileCfgPresets, 0, 16)
|
||||
presetStr := strings.TrimSpace(parseEnv("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)
|
||||
}
|
||||
}
|
||||
|
||||
concretizePresets, err := presets.concretize()
|
||||
|
||||
if err != nil {
|
||||
return enviroTypeName, Configuration{}, fmt.Errorf(
|
||||
"unable to parse Preset data: %s", err)
|
||||
}
|
||||
|
||||
return enviroTypeName, Configuration{
|
||||
HostName: cfg.HostName,
|
||||
SharedKey: cfg.SharedKey,
|
||||
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
|
||||
Socks5: cfg.Socks5,
|
||||
Socks5User: cfg.Socks5User,
|
||||
Socks5Password: cfg.Socks5Password,
|
||||
Servers: []Server{cfgSer.build()},
|
||||
Presets: concretizePresets,
|
||||
OnlyAllowPresetRemotes: cfg.OnlyAllowPresetRemotes,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
270
application/configuration/loader_file.go
Normal file
270
application/configuration/loader_file.go
Normal file
@ -0,0 +1,270 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
const (
|
||||
fileTypeName = "File"
|
||||
)
|
||||
|
||||
type fileCfgServer struct {
|
||||
ListenInterface string // Interface to listen to
|
||||
ListenPort uint16 // Port to listen
|
||||
InitialTimeout int // Client initial request timeout, in second
|
||||
ReadTimeout int // Read operation timeout, in second
|
||||
WriteTimeout int // Write operation timeout, in second
|
||||
HeartbeatTimeout int // Client heartbeat interval, in second
|
||||
ReadDelay int // Read delay, in millisecond
|
||||
WriteDelay int // Write delay, in millisecond
|
||||
TLSCertificateFile string // Location of TLS certificate file
|
||||
TLSCertificateKeyFile string // Location of TLS certificate key
|
||||
ServerMessage string // Server message displayed on the Home page
|
||||
}
|
||||
|
||||
func (f *fileCfgServer) build() Server {
|
||||
iface := f.ListenInterface
|
||||
|
||||
if len(iface) <= 0 {
|
||||
iface = "127.0.0.1"
|
||||
}
|
||||
|
||||
return Server{
|
||||
ListenInterface: iface,
|
||||
ListenPort: f.ListenPort,
|
||||
InitialTimeout: time.Duration(
|
||||
durationAtLeast(f.InitialTimeout, 5)) * time.Second,
|
||||
ReadTimeout: time.Duration(
|
||||
durationAtLeast(f.ReadTimeout, 30)) * time.Second,
|
||||
WriteTimeout: time.Duration(
|
||||
durationAtLeast(f.WriteTimeout, 30)) * time.Second,
|
||||
HeartbeatTimeout: time.Duration(
|
||||
durationAtLeast(f.HeartbeatTimeout, 10)) * time.Second,
|
||||
ReadDelay: time.Duration(
|
||||
durationAtLeast(f.ReadDelay, 0)) * time.Millisecond,
|
||||
WriteDelay: time.Duration(
|
||||
durationAtLeast(f.WriteDelay, 0)) * time.Millisecond,
|
||||
TLSCertificateFile: f.TLSCertificateFile,
|
||||
TLSCertificateKeyFile: f.TLSCertificateKeyFile,
|
||||
ServerMessage: f.ServerMessage,
|
||||
}
|
||||
}
|
||||
|
||||
type fileCfgPreset struct {
|
||||
Title string
|
||||
Type string
|
||||
Host string
|
||||
Meta Meta
|
||||
}
|
||||
|
||||
func (f fileCfgPreset) concretize() (Preset, error) {
|
||||
m, err := f.Meta.Concretize()
|
||||
|
||||
if err != nil {
|
||||
return Preset{}, err
|
||||
}
|
||||
|
||||
return Preset{
|
||||
Title: f.Title,
|
||||
Type: strings.TrimSpace(f.Type),
|
||||
Host: f.Host,
|
||||
Meta: m,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fileCfgPresets []fileCfgPreset
|
||||
|
||||
func (f fileCfgPresets) concretize() ([]Preset, error) {
|
||||
ps := make([]Preset, 0, len(f))
|
||||
|
||||
for i, p := range f {
|
||||
pp, err := p.concretize()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"unable to concretize Preset %d (titled \"%s\"): %s",
|
||||
i+1, p.Title, err)
|
||||
}
|
||||
|
||||
ps = append(ps, pp)
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
type fileCfgCommon struct {
|
||||
// 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 fileCfgPresets
|
||||
|
||||
// Allow predefined remotes only
|
||||
OnlyAllowPresetRemotes bool
|
||||
}
|
||||
|
||||
func (f fileCfgCommon) build() (fileCfgCommon, error) {
|
||||
return fileCfgCommon{
|
||||
HostName: f.HostName,
|
||||
SharedKey: f.SharedKey,
|
||||
DialTimeout: durationAtLeast(f.DialTimeout, 5),
|
||||
Socks5: f.Socks5,
|
||||
Socks5User: f.Socks5User,
|
||||
Socks5Password: f.Socks5Password,
|
||||
Servers: f.Servers,
|
||||
Presets: f.Presets,
|
||||
OnlyAllowPresetRemotes: f.OnlyAllowPresetRemotes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadFile(filePath string) (string, Configuration, error) {
|
||||
f, fErr := os.Open(filePath)
|
||||
|
||||
if fErr != nil {
|
||||
return fileTypeName, Configuration{}, fErr
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
cfg := fileCfgCommon{}
|
||||
|
||||
jDecoder := json.NewDecoder(f)
|
||||
jDecodeErr := jDecoder.Decode(&cfg)
|
||||
|
||||
if jDecodeErr != nil {
|
||||
return fileTypeName, Configuration{}, jDecodeErr
|
||||
}
|
||||
|
||||
finalCfg, cfgErr := cfg.build()
|
||||
|
||||
if cfgErr != nil {
|
||||
return fileTypeName, Configuration{}, cfgErr
|
||||
}
|
||||
|
||||
servers := make([]Server, len(finalCfg.Servers))
|
||||
|
||||
for i := range servers {
|
||||
servers[i] = finalCfg.Servers[i].build()
|
||||
}
|
||||
|
||||
presets, err := finalCfg.Presets.concretize()
|
||||
|
||||
if err != nil {
|
||||
return fileTypeName, Configuration{}, err
|
||||
}
|
||||
|
||||
return fileTypeName, Configuration{
|
||||
HostName: finalCfg.HostName,
|
||||
SharedKey: finalCfg.SharedKey,
|
||||
DialTimeout: time.Duration(finalCfg.DialTimeout) *
|
||||
time.Second,
|
||||
Socks5: cfg.Socks5,
|
||||
Socks5User: cfg.Socks5User,
|
||||
Socks5Password: cfg.Socks5Password,
|
||||
Servers: servers,
|
||||
Presets: presets,
|
||||
OnlyAllowPresetRemotes: cfg.OnlyAllowPresetRemotes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// File creates a configuration file loader
|
||||
func File(customPath string) Loader {
|
||||
return func(log log.Logger) (string, Configuration, error) {
|
||||
if len(customPath) > 0 {
|
||||
log.Info("Loading configuration from: %s", customPath)
|
||||
|
||||
return loadFile(customPath)
|
||||
}
|
||||
|
||||
log.Info("Loading configuration from one of the default " +
|
||||
"configuration files ...")
|
||||
|
||||
fallbackFileSearchList := make([]string, 0, 3)
|
||||
|
||||
// ~/.config/sshwifty.conf.json
|
||||
u, userErr := user.Current()
|
||||
if userErr == nil {
|
||||
fallbackFileSearchList = append(
|
||||
fallbackFileSearchList,
|
||||
path.Join(u.HomeDir, ".config", "sshwifty.conf.json"))
|
||||
}
|
||||
|
||||
// /etc/sshwifty.conf.json
|
||||
fallbackFileSearchList = append(
|
||||
fallbackFileSearchList, "/etc/sshwifty.conf.json")
|
||||
|
||||
// sshwifty.conf.json located at the same directory as Sshwifty bin
|
||||
ex, exErr := os.Executable()
|
||||
if exErr == nil {
|
||||
fallbackFileSearchList = append(
|
||||
fallbackFileSearchList,
|
||||
path.Join(filepath.Dir(ex), "sshwifty.conf.json"))
|
||||
}
|
||||
|
||||
for f := range fallbackFileSearchList {
|
||||
fInfo, fErr := os.Stat(fallbackFileSearchList[f])
|
||||
|
||||
if fErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if fInfo.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info("Configuration file \"%s\" has been selected",
|
||||
fallbackFileSearchList[f])
|
||||
|
||||
return loadFile(fallbackFileSearchList[f])
|
||||
}
|
||||
|
||||
return fileTypeName, Configuration{}, fmt.Errorf(
|
||||
"Configuration file was not specified. Also tried fallback files "+
|
||||
"\"%s\", but none of it was available",
|
||||
strings.Join(fallbackFileSearchList, "\", \""))
|
||||
}
|
||||
}
|
||||
52
application/configuration/loader_redundant.go
Normal file
52
application/configuration/loader_redundant.go
Normal file
@ -0,0 +1,52 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
const (
|
||||
redundantTypeName = "Redundant"
|
||||
)
|
||||
|
||||
// Redundant creates a group of loaders. They will be executed one by one until
|
||||
// one of it successfully returned a configuration
|
||||
func Redundant(loaders ...Loader) Loader {
|
||||
return func(log log.Logger) (string, Configuration, error) {
|
||||
ll := log.Context("Redundant")
|
||||
|
||||
for i := range loaders {
|
||||
lLoaderName, lCfg, lErr := loaders[i](ll)
|
||||
|
||||
if lErr != nil {
|
||||
ll.Warning("Unable to load configuration from \"%s\": %s",
|
||||
lLoaderName, lErr)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return lLoaderName, lCfg, nil
|
||||
}
|
||||
|
||||
return redundantTypeName, Configuration{}, fmt.Errorf(
|
||||
"all existing redundant loader has failed")
|
||||
}
|
||||
}
|
||||
79
application/configuration/string.go
Normal file
79
application/configuration/string.go
Normal file
@ -0,0 +1,79 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// String represents a config string
|
||||
type String string
|
||||
|
||||
// Parse parses current string and return the parsed result
|
||||
func (s String) Parse() (string, error) {
|
||||
ss := string(s)
|
||||
|
||||
sSchemeLeadIdx := strings.Index(ss, "://")
|
||||
|
||||
if sSchemeLeadIdx < 0 {
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
sSchemeLeadEnd := sSchemeLeadIdx + 3
|
||||
|
||||
switch strings.ToLower(ss[:sSchemeLeadIdx]) {
|
||||
case "file":
|
||||
fPath, e := filepath.Abs(ss[sSchemeLeadEnd:])
|
||||
|
||||
if e != nil {
|
||||
return ss, e
|
||||
}
|
||||
|
||||
f, e := os.Open(fPath)
|
||||
|
||||
if e != nil {
|
||||
return "", fmt.Errorf("unable to open %s: %s", fPath, e)
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
fData, e := ioutil.ReadAll(f)
|
||||
|
||||
if e != nil {
|
||||
return "", fmt.Errorf("unable to read from %s: %s", fPath, e)
|
||||
}
|
||||
|
||||
return string(fData), nil
|
||||
|
||||
case "enviroment": // You see what I did there. Remove this a later
|
||||
fallthrough
|
||||
case "environment":
|
||||
return os.Getenv(ss[sSchemeLeadEnd:]), nil
|
||||
|
||||
case "literal":
|
||||
return ss[sSchemeLeadEnd:], nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"scheme \"%s\" was unsupported", ss[:sSchemeLeadIdx])
|
||||
}
|
||||
}
|
||||
94
application/configuration/string_test.go
Normal file
94
application/configuration/string_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStringString(t *testing.T) {
|
||||
ss := String("aaaaaaaaaaaaa")
|
||||
|
||||
result, err := ss.Parse()
|
||||
|
||||
if err != nil {
|
||||
t.Error("Unable to parse:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if result != "aaaaaaaaaaaaa" {
|
||||
t.Errorf(
|
||||
"Expecting the result to be %s, got %s instead",
|
||||
"aaaaaaaaaaaaa",
|
||||
result,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringFile(t *testing.T) {
|
||||
const testFilename = "sshwifty.configuration.test.string.file.tmp"
|
||||
|
||||
filePath := os.TempDir() + string(os.PathSeparator) + testFilename
|
||||
|
||||
f, err := os.Create(filePath)
|
||||
|
||||
if err != nil {
|
||||
t.Error("Unable to create file:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer os.Remove(filePath)
|
||||
|
||||
f.WriteString("TestAAAA")
|
||||
f.Close()
|
||||
|
||||
ss := String("file://" + filePath)
|
||||
|
||||
result, err := ss.Parse()
|
||||
|
||||
if err != nil {
|
||||
t.Error("Unable to parse:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if result != "TestAAAA" {
|
||||
t.Errorf(
|
||||
"Expecting the result to be %s, got %s instead",
|
||||
"TestAAAA",
|
||||
result,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ss = String("file://" + filePath + ".notexist")
|
||||
|
||||
_, err = ss.Parse()
|
||||
|
||||
if err == nil {
|
||||
t.Error("Parsing an non-existing file should result an error")
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
132
application/controller/base.go
Normal file
132
application/controller/base.go
Normal file
@ -0,0 +1,132 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
// Error
|
||||
var (
|
||||
ErrControllerNotImplemented = NewError(
|
||||
http.StatusNotImplemented, "Server does not know how to handle the "+
|
||||
"request")
|
||||
)
|
||||
|
||||
type controller interface {
|
||||
Get(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Head(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Post(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Put(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Delete(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Connect(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Options(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Trace(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Patch(w http.ResponseWriter, r *http.Request, l log.Logger) error
|
||||
Other(
|
||||
method string,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
l log.Logger,
|
||||
) error
|
||||
}
|
||||
|
||||
type baseController struct{}
|
||||
|
||||
func (b baseController) Get(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Head(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Post(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Put(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Delete(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Connect(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Options(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Trace(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Patch(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func (b baseController) Other(
|
||||
method string, w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return ErrControllerNotImplemented
|
||||
}
|
||||
|
||||
func serveController(
|
||||
c controller,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
l log.Logger,
|
||||
) error {
|
||||
switch strings.ToUpper(r.Method) {
|
||||
case "GET":
|
||||
return c.Get(w, r, l)
|
||||
case "HEAD":
|
||||
return c.Head(w, r, l)
|
||||
case "POST":
|
||||
return c.Post(w, r, l)
|
||||
case "PUT":
|
||||
return c.Put(w, r, l)
|
||||
case "DELETE":
|
||||
return c.Delete(w, r, l)
|
||||
case "CONNECT":
|
||||
return c.Connect(w, r, l)
|
||||
case "OPTIONS":
|
||||
return c.Options(w, r, l)
|
||||
case "TRACE":
|
||||
return c.Trace(w, r, l)
|
||||
case "PATCH":
|
||||
return c.Patch(w, r, l)
|
||||
default:
|
||||
return c.Other(r.Method, w, r, l)
|
||||
}
|
||||
}
|
||||
61
application/controller/common.go
Normal file
61
application/controller/common.go
Normal file
@ -0,0 +1,61 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func clientSupportGZIP(r *http.Request) bool {
|
||||
// Should be good enough
|
||||
return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
||||
}
|
||||
|
||||
var (
|
||||
serverMessageFormatLink = regexp.MustCompile(`\[(.*?)\]\((.*?)\)`)
|
||||
)
|
||||
|
||||
func parseServerMessage(input string) (result string) {
|
||||
// Yep, this is a new low, throwing regexp at a flat text format now...will
|
||||
// rewrite the entire thing in a new version with a proper parser, maybe
|
||||
// Con: Barely work when we only need to support exactly one text format
|
||||
// Pro: Expecting a debugging battle, wrote the thing in one go instead
|
||||
found := serverMessageFormatLink.FindAllStringSubmatchIndex(input, -1)
|
||||
if len(found) <= 0 {
|
||||
return input
|
||||
}
|
||||
currentStart := 0
|
||||
for _, f := range found {
|
||||
if len(f) != 6 { // Expecting 6 parameters from the given expression
|
||||
return input
|
||||
}
|
||||
segStart, segEnd, titleStart, titleEnd, linkStart, linkEnd :=
|
||||
f[0], f[1], f[2], f[3], f[4], f[5]
|
||||
result += input[currentStart:segStart]
|
||||
result += "<a href=\"" +
|
||||
input[linkStart:linkEnd] +
|
||||
"\" target=\"_blank\">" +
|
||||
input[titleStart:titleEnd] +
|
||||
"</a>"
|
||||
currentStart = segEnd
|
||||
}
|
||||
result += input[currentStart:]
|
||||
return
|
||||
}
|
||||
54
application/controller/common_test.go
Normal file
54
application/controller/common_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"html"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseServerMessage(t *testing.T) {
|
||||
for _, test := range [][]string{
|
||||
{
|
||||
"<b>This is a [测试](http://nirui.org) " +
|
||||
"[for link support](http://nirui.org)</b>.",
|
||||
"<b>This is a " +
|
||||
"<a href=\"http://nirui.org\" target=\"_blank\">测试</a> " +
|
||||
"<a href=\"http://nirui.org\" target=\"_blank\">for link support</a>" +
|
||||
"</b>.",
|
||||
},
|
||||
{
|
||||
"[测试](http://nirui.org)",
|
||||
"<a href=\"http://nirui.org\" target=\"_blank\">测试</a>",
|
||||
},
|
||||
{
|
||||
"[测试](http://nirui.org).",
|
||||
"<a href=\"http://nirui.org\" target=\"_blank\">测试</a>.",
|
||||
},
|
||||
{
|
||||
".[测试](http://nirui.org)",
|
||||
".<a href=\"http://nirui.org\" target=\"_blank\">测试</a>",
|
||||
},
|
||||
} {
|
||||
result := parseServerMessage(html.EscapeString(test[0]))
|
||||
if result != test[1] {
|
||||
t.Errorf("Expecting %v, got %v instead", test[1], result)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
172
application/controller/controller.go
Normal file
172
application/controller/controller.go
Normal file
@ -0,0 +1,172 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/command"
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/server"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrNotFound = NewError(
|
||||
http.StatusNotFound, "Page not found")
|
||||
)
|
||||
|
||||
const (
|
||||
assetsURLPrefix = "/sshwifty/assets/"
|
||||
assetsURLPrefixLen = len(assetsURLPrefix)
|
||||
)
|
||||
|
||||
// handler is the main service dispatcher
|
||||
type handler struct {
|
||||
hostNameChecker string
|
||||
commonCfg configuration.Common
|
||||
logger log.Logger
|
||||
homeCtl home
|
||||
socketCtl socket
|
||||
socketVerifyCtl socketVerification
|
||||
}
|
||||
|
||||
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
|
||||
clientLogger := h.logger.Context("Client (%s)", r.RemoteAddr)
|
||||
|
||||
if len(h.commonCfg.HostName) > 0 {
|
||||
hostPort := r.Host
|
||||
|
||||
if len(hostPort) <= 0 {
|
||||
hostPort = r.URL.Host
|
||||
}
|
||||
|
||||
if h.commonCfg.HostName != hostPort &&
|
||||
!strings.HasPrefix(hostPort, h.hostNameChecker) {
|
||||
clientLogger.Warning("Requested invalid host \"%s\", denied access",
|
||||
r.Host)
|
||||
|
||||
serveFailure(
|
||||
NewError(http.StatusForbidden, "Invalid host"), w, r, h.logger)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Date", time.Now().UTC().Format(time.RFC1123))
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
err = serveController(h.homeCtl, w, r, clientLogger)
|
||||
|
||||
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(
|
||||
"robots.txt",
|
||||
staticFileExt(".txt"),
|
||||
w,
|
||||
r,
|
||||
clientLogger)
|
||||
|
||||
case "/favicon.ico":
|
||||
err = serveStaticCacheData(
|
||||
"favicon.ico",
|
||||
staticFileExt(".ico"),
|
||||
w,
|
||||
r,
|
||||
clientLogger)
|
||||
|
||||
case "/manifest.json":
|
||||
err = serveStaticCacheData(
|
||||
"manifest.json",
|
||||
staticFileExt(".json"),
|
||||
w,
|
||||
r,
|
||||
clientLogger)
|
||||
|
||||
case "/browserconfig.xml":
|
||||
err = serveStaticCacheData(
|
||||
"browserconfig.xml",
|
||||
staticFileExt(".xml"),
|
||||
w,
|
||||
r,
|
||||
clientLogger)
|
||||
|
||||
default:
|
||||
if strings.HasPrefix(r.URL.Path, assetsURLPrefix) &&
|
||||
strings.ToUpper(r.Method) == "GET" {
|
||||
err = serveStaticCacheData(
|
||||
r.URL.Path[assetsURLPrefixLen:],
|
||||
staticFileExt(r.URL.Path[assetsURLPrefixLen:]),
|
||||
w,
|
||||
r,
|
||||
clientLogger)
|
||||
} else {
|
||||
err = ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
clientLogger.Info("Request completed: %s", r.URL.String())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clientLogger.Warning("Request ended with error: %s: %s",
|
||||
r.URL.String(), err)
|
||||
|
||||
controllerErr, isControllerErr := err.(Error)
|
||||
|
||||
if isControllerErr {
|
||||
serveFailure(controllerErr, w, r, h.logger)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
serveFailure(
|
||||
NewError(http.StatusInternalServerError, err.Error()), w, r, h.logger)
|
||||
}
|
||||
|
||||
// Builder returns a http controller builder
|
||||
func Builder(cmds command.Commands) server.HandlerBuilder {
|
||||
return func(
|
||||
commonCfg configuration.Common,
|
||||
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: socketCtl,
|
||||
socketVerifyCtl: newSocketVerification(socketCtl, cfg, commonCfg),
|
||||
}
|
||||
}
|
||||
}
|
||||
44
application/controller/error.go
Normal file
44
application/controller/error.go
Normal file
@ -0,0 +1,44 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Error Controller error
|
||||
type Error struct {
|
||||
code int
|
||||
message string
|
||||
}
|
||||
|
||||
// NewError creates a new Error
|
||||
func NewError(code int, message string) Error {
|
||||
return Error{
|
||||
code: code,
|
||||
message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// Code return the error code
|
||||
func (f Error) Code() int {
|
||||
return f.code
|
||||
}
|
||||
|
||||
// Error returns the error message
|
||||
func (f Error) Error() string {
|
||||
return fmt.Sprintf("HTTP Error (%d): %s", f.code, f.message)
|
||||
}
|
||||
33
application/controller/failure.go
Normal file
33
application/controller/failure.go
Normal file
@ -0,0 +1,33 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
func serveFailure(
|
||||
err Error,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
l log.Logger,
|
||||
) error {
|
||||
return serveStaticPage("error.html", err.Code(), w, r, l)
|
||||
}
|
||||
33
application/controller/home.go
Normal file
33
application/controller/home.go
Normal file
@ -0,0 +1,33 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
// home controller
|
||||
type home struct {
|
||||
baseController
|
||||
}
|
||||
|
||||
func (h home) Get(w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return serveStaticPage("index.html", http.StatusOK, w, r, l)
|
||||
}
|
||||
393
application/controller/socket.go
Normal file
393
application/controller/socket.go
Normal file
@ -0,0 +1,393 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha512"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/nirui/sshwifty/application/command"
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
"github.com/nirui/sshwifty/application/rw"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrSocketInvalidAuthKey = NewError(
|
||||
http.StatusForbidden,
|
||||
"To use Websocket interface, a valid Auth Key must be provided")
|
||||
|
||||
ErrSocketAuthFailed = NewError(
|
||||
http.StatusForbidden,
|
||||
"Authentication has failed with provided Auth Key")
|
||||
|
||||
ErrSocketUnableToGenerateKey = NewError(
|
||||
http.StatusInternalServerError,
|
||||
"Unable to generate crypto key")
|
||||
|
||||
ErrSocketInvalidDataPackage = NewError(
|
||||
http.StatusBadRequest, "Invalid data package")
|
||||
)
|
||||
|
||||
const (
|
||||
socketGCMStandardNonceSize = 12
|
||||
)
|
||||
|
||||
type socket struct {
|
||||
baseController
|
||||
|
||||
commonCfg configuration.Common
|
||||
serverCfg configuration.Server
|
||||
upgrader websocket.Upgrader
|
||||
commander command.Commander
|
||||
}
|
||||
|
||||
func hashCombineSocketKeys(addedKey string, privateKey string) []byte {
|
||||
h := hmac.New(sha512.New, []byte(privateKey))
|
||||
|
||||
h.Write([]byte(addedKey))
|
||||
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func newSocketCtl(
|
||||
commonCfg configuration.Common,
|
||||
cfg configuration.Server,
|
||||
cmds command.Commands,
|
||||
) socket {
|
||||
return socket{
|
||||
commonCfg: commonCfg,
|
||||
serverCfg: cfg,
|
||||
upgrader: buildWebsocketUpgrader(cfg),
|
||||
commander: command.New(cmds),
|
||||
}
|
||||
}
|
||||
|
||||
type websocketWriter struct {
|
||||
*websocket.Conn
|
||||
}
|
||||
|
||||
func (w websocketWriter) Write(b []byte) (int, error) {
|
||||
wErr := w.WriteMessage(websocket.BinaryMessage, b)
|
||||
|
||||
if wErr != nil {
|
||||
return 0, wErr
|
||||
}
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
type socketPackageWriter struct {
|
||||
w websocketWriter
|
||||
packager func(w websocketWriter, b []byte) error
|
||||
}
|
||||
|
||||
func (s socketPackageWriter) Write(b []byte) (int, error) {
|
||||
packageWriteErr := s.packager(s.w, b)
|
||||
|
||||
if packageWriteErr != nil {
|
||||
return 0, packageWriteErr
|
||||
}
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func buildWebsocketUpgrader(cfg configuration.Server) websocket.Upgrader {
|
||||
return websocket.Upgrader{
|
||||
HandshakeTimeout: cfg.InitialTimeout,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
Error: func(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
status int,
|
||||
reason error,
|
||||
) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s socket) Options(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Access-Control-Allow-Headers", "X-Key")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s socket) buildWSFetcher(c *websocket.Conn) rw.FetchReaderFetcher {
|
||||
return func() ([]byte, error) {
|
||||
for {
|
||||
mt, message, err := c.ReadMessage()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mt != websocket.BinaryMessage {
|
||||
return nil, NewError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("Received unknown type of data: %d", message))
|
||||
}
|
||||
|
||||
return message, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s socket) generateNonce(nonce []byte) error {
|
||||
_, rErr := io.ReadFull(rand.Reader, nonce[:socketGCMStandardNonceSize])
|
||||
|
||||
return rErr
|
||||
}
|
||||
|
||||
func (s socket) increaseNonce(nonce []byte) {
|
||||
for i := len(nonce); i > 0; i-- {
|
||||
nonce[i-1]++
|
||||
|
||||
if nonce[i-1] <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func (s socket) createCipher(key []byte) (cipher.AEAD, cipher.AEAD, error) {
|
||||
readCipher, readCipherErr := aes.NewCipher(key)
|
||||
|
||||
if readCipherErr != nil {
|
||||
return nil, nil, readCipherErr
|
||||
}
|
||||
|
||||
writeCipher, writeCipherErr := aes.NewCipher(key)
|
||||
|
||||
if writeCipherErr != nil {
|
||||
return nil, nil, writeCipherErr
|
||||
}
|
||||
|
||||
gcmRead, gcmReadErr := cipher.NewGCMWithNonceSize(
|
||||
readCipher, socketGCMStandardNonceSize)
|
||||
|
||||
if gcmReadErr != nil {
|
||||
return nil, nil, gcmReadErr
|
||||
}
|
||||
|
||||
gcmWrite, gcmWriteErr := cipher.NewGCMWithNonceSize(
|
||||
writeCipher, socketGCMStandardNonceSize)
|
||||
|
||||
if gcmWriteErr != nil {
|
||||
return nil, nil, gcmWriteErr
|
||||
}
|
||||
|
||||
return gcmRead, gcmWrite, nil
|
||||
}
|
||||
|
||||
func (s socket) mixerKey(r *http.Request) []byte {
|
||||
return hashCombineSocketKeys(
|
||||
r.UserAgent(), s.commonCfg.SharedKey+"+"+s.commonCfg.HostName)
|
||||
}
|
||||
|
||||
const keyTimeTruncater = 100
|
||||
|
||||
func (s socket) buildCipherKey(r *http.Request) [16]byte {
|
||||
key := [16]byte{}
|
||||
|
||||
copy(key[:], hashCombineSocketKeys(
|
||||
strconv.FormatInt(time.Now().Unix()/keyTimeTruncater, 10),
|
||||
string(s.mixerKey(r))+"+"+s.commonCfg.SharedKey,
|
||||
))
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func (s socket) Get(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
// Error will not be returned when Websocket already handled
|
||||
// (i.e. returned the error to client). We just log the error and that's it
|
||||
c, err := s.upgrader.Upgrade(w, r, nil)
|
||||
|
||||
if err != nil {
|
||||
return NewError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
defer c.Close()
|
||||
|
||||
wsReader := rw.NewFetchReader(s.buildWSFetcher(c))
|
||||
wsWriter := websocketWriter{Conn: c}
|
||||
|
||||
// Initialize ciphers
|
||||
//
|
||||
// WARNING: The AES-GCM cipher is here for authenticating user login. Yeah
|
||||
// it is overkill and probably not correct. But I eventually decide
|
||||
// to keep it as long as it authenticates (Hopefully in a safe and
|
||||
// secured way).
|
||||
//
|
||||
// DO NOT rely on this if you want to secured communitcation, in
|
||||
// that case, you need to use HTTPS.
|
||||
//
|
||||
readNonce := [socketGCMStandardNonceSize]byte{}
|
||||
_, nonceReadErr := io.ReadFull(&wsReader, readNonce[:])
|
||||
|
||||
if nonceReadErr != nil {
|
||||
return NewError(http.StatusBadRequest, fmt.Sprintf(
|
||||
"Unable to read initial client nonce: %s", nonceReadErr.Error()))
|
||||
}
|
||||
|
||||
writeNonce := [socketGCMStandardNonceSize]byte{}
|
||||
nonceReadErr = s.generateNonce(writeNonce[:])
|
||||
|
||||
if nonceReadErr != nil {
|
||||
return NewError(http.StatusBadRequest, fmt.Sprintf(
|
||||
"Unable to generate initial server nonce: %s",
|
||||
nonceReadErr.Error()))
|
||||
}
|
||||
|
||||
_, nonceSendErr := wsWriter.Write(writeNonce[:])
|
||||
|
||||
if nonceSendErr != nil {
|
||||
return NewError(http.StatusBadRequest, fmt.Sprintf(
|
||||
"Unable to send server nonce to client: %s", nonceSendErr.Error()))
|
||||
}
|
||||
|
||||
cipherKey := s.buildCipherKey(r)
|
||||
|
||||
readCipher, writeCipher, cipherCreationErr := s.createCipher(cipherKey[:])
|
||||
|
||||
if cipherCreationErr != nil {
|
||||
return NewError(http.StatusInternalServerError, fmt.Sprintf(
|
||||
"Unable to create cipher: %s", cipherCreationErr.Error()))
|
||||
}
|
||||
|
||||
// Start service
|
||||
const cipherReadBufSize = 4096
|
||||
|
||||
cipherReadBuf := [cipherReadBufSize]byte{}
|
||||
cipherWriteBuf := [cipherReadBufSize]byte{}
|
||||
maxWriteLen := int(cipherReadBufSize) - (writeCipher.Overhead() + 2)
|
||||
|
||||
senderLock := sync.Mutex{}
|
||||
cmdExec, cmdExecErr := s.commander.New(
|
||||
command.Configuration{
|
||||
Dial: s.commonCfg.Dialer,
|
||||
DialTimeout: s.commonCfg.DecideDialTimeout(s.serverCfg.ReadTimeout),
|
||||
},
|
||||
rw.NewFetchReader(func() ([]byte, error) {
|
||||
defer s.increaseNonce(readNonce[:])
|
||||
|
||||
// Size is unencrypted
|
||||
_, rErr := io.ReadFull(&wsReader, cipherReadBuf[:2])
|
||||
|
||||
if rErr != nil {
|
||||
return nil, rErr
|
||||
}
|
||||
|
||||
// Read full size
|
||||
packageSize := uint16(cipherReadBuf[0])
|
||||
packageSize <<= 8
|
||||
packageSize |= uint16(cipherReadBuf[1])
|
||||
|
||||
if packageSize <= 0 || packageSize > cipherReadBufSize {
|
||||
return nil, ErrSocketInvalidDataPackage
|
||||
}
|
||||
|
||||
if int(packageSize) <= wsReader.Remain() {
|
||||
rData, rErr := wsReader.Export(int(packageSize))
|
||||
|
||||
if rErr != nil {
|
||||
return nil, rErr
|
||||
}
|
||||
|
||||
return readCipher.Open(
|
||||
cipherReadBuf[:0], readNonce[:], rData, nil)
|
||||
}
|
||||
|
||||
_, rErr = io.ReadFull(&wsReader, cipherReadBuf[:packageSize])
|
||||
|
||||
if rErr != nil {
|
||||
return nil, rErr
|
||||
}
|
||||
|
||||
return readCipher.Open(
|
||||
cipherReadBuf[:0],
|
||||
readNonce[:],
|
||||
cipherReadBuf[:packageSize],
|
||||
nil)
|
||||
}),
|
||||
socketPackageWriter{
|
||||
w: wsWriter,
|
||||
packager: func(w websocketWriter, b []byte) error {
|
||||
start := 0
|
||||
bLen := len(b)
|
||||
readLen := bLen
|
||||
|
||||
for start < bLen {
|
||||
if readLen > maxWriteLen {
|
||||
readLen = maxWriteLen
|
||||
}
|
||||
|
||||
encrypted := writeCipher.Seal(
|
||||
cipherWriteBuf[2:2],
|
||||
writeNonce[:],
|
||||
b[start:start+readLen],
|
||||
nil)
|
||||
|
||||
s.increaseNonce(writeNonce[:])
|
||||
|
||||
encryptedSize := uint16(len(encrypted))
|
||||
|
||||
if encryptedSize <= 0 {
|
||||
return ErrSocketInvalidDataPackage
|
||||
}
|
||||
|
||||
cipherWriteBuf[0] = byte(encryptedSize >> 8)
|
||||
cipherWriteBuf[1] = byte(encryptedSize)
|
||||
|
||||
_, wErr := w.Write(cipherWriteBuf[:encryptedSize+2])
|
||||
|
||||
if wErr != nil {
|
||||
return wErr
|
||||
}
|
||||
|
||||
start += readLen
|
||||
readLen = bLen - start
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}, &senderLock, s.serverCfg.ReadDelay, s.serverCfg.WriteDelay, l)
|
||||
|
||||
if cmdExecErr != nil {
|
||||
return NewError(http.StatusBadRequest, cmdExecErr.Error())
|
||||
}
|
||||
|
||||
return cmdExec.Handle()
|
||||
}
|
||||
180
application/controller/socket_verify.go
Normal file
180
application/controller/socket_verify.go
Normal file
@ -0,0 +1,180 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/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"`
|
||||
}
|
||||
|
||||
type socketAccessConfiguration struct {
|
||||
Presets []socketRemotePreset `json:"presets"`
|
||||
ServerMessage string `json:"server_message"`
|
||||
}
|
||||
|
||||
func newSocketAccessConfiguration(
|
||||
remotes []configuration.Preset,
|
||||
serverMessage string,
|
||||
) socketAccessConfiguration {
|
||||
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,
|
||||
}
|
||||
}
|
||||
return socketAccessConfiguration{
|
||||
Presets: presets,
|
||||
ServerMessage: parseServerMessage(html.EscapeString(serverMessage)),
|
||||
}
|
||||
}
|
||||
|
||||
func buildAccessConfigRespondBody(accessCfg socketAccessConfiguration) []byte {
|
||||
mData, mErr := json.Marshal(accessCfg)
|
||||
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(
|
||||
newSocketAccessConfiguration(
|
||||
commCfg.Presets,
|
||||
srvCfg.ServerMessage,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (s socketVerification) authKey(r *http.Request) []byte {
|
||||
timeMixer := strconv.FormatInt(time.Now().Unix()/100, 10)
|
||||
|
||||
if len(s.commonCfg.SharedKey) > 0 {
|
||||
return hashCombineSocketKeys(
|
||||
timeMixer,
|
||||
s.commonCfg.SharedKey,
|
||||
)[:32]
|
||||
}
|
||||
|
||||
return hashCombineSocketKeys(
|
||||
timeMixer,
|
||||
"DEFAULT VERIFY KEY",
|
||||
)[:32]
|
||||
}
|
||||
|
||||
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()
|
||||
hd.Add("Cache-Control", "no-store")
|
||||
hd.Add("Pragma", "no-store")
|
||||
|
||||
key := r.Header.Get("X-Key")
|
||||
|
||||
if len(key) <= 0 {
|
||||
hd.Add("X-Key", base64.StdEncoding.EncodeToString(s.mixerKey(r)))
|
||||
|
||||
if len(s.commonCfg.SharedKey) <= 0 {
|
||||
s.setServerConfigRespond(&hd, w)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrSocketInvalidAuthKey
|
||||
}
|
||||
|
||||
if len(key) > 64 {
|
||||
return ErrSocketInvalidAuthKey
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
authKey := s.authKey(r)
|
||||
|
||||
if !hmac.Equal(authKey, decodedKey) {
|
||||
return ErrSocketAuthFailed
|
||||
}
|
||||
|
||||
hd.Add("X-Key", base64.StdEncoding.EncodeToString(s.mixerKey(r)))
|
||||
s.setServerConfigRespond(&hd, w)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s socketVerification) Options(
|
||||
w http.ResponseWriter, r *http.Request, l log.Logger) error {
|
||||
return nil
|
||||
}
|
||||
125
application/controller/static.go
Normal file
125
application/controller/static.go
Normal file
@ -0,0 +1,125 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//go:generate go run ./static_page_generater ../../.tmp/dist ./static_pages.go
|
||||
//go:generate go fmt ./static_pages.go
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
type staticData struct {
|
||||
data []byte
|
||||
dataHash string
|
||||
compressd []byte
|
||||
compressdHash string
|
||||
created time.Time
|
||||
contentType string
|
||||
}
|
||||
|
||||
func (s staticData) hasCompressed() bool {
|
||||
return len(s.compressd) > 0
|
||||
}
|
||||
|
||||
func staticFileExt(fileName string) string {
|
||||
extIdx := strings.LastIndex(fileName, ".")
|
||||
if extIdx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(fileName[extIdx:])
|
||||
}
|
||||
|
||||
func serveStaticCacheData(
|
||||
dataName string,
|
||||
fileExt string,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
l log.Logger,
|
||||
) error {
|
||||
if fileExt == ".html" || fileExt == ".htm" {
|
||||
return ErrNotFound
|
||||
}
|
||||
return serveStaticCachePage(dataName, w, r, l)
|
||||
}
|
||||
|
||||
func serveStaticCachePage(
|
||||
dataName string,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
l log.Logger,
|
||||
) error {
|
||||
d, dFound := staticPages[dataName]
|
||||
if !dFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
selectedData := d.data
|
||||
selectedLength := len(d.data)
|
||||
compressEnabled := false
|
||||
if clientSupportGZIP(r) && d.hasCompressed() {
|
||||
selectedData = d.compressd
|
||||
selectedLength = len(d.compressd)
|
||||
compressEnabled = true
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
}
|
||||
w.Header().Add("Cache-Control", "public, max-age=5184000")
|
||||
w.Header().Add("Content-Type", d.contentType)
|
||||
if compressEnabled {
|
||||
w.Header().Add("Content-Encoding", "gzip")
|
||||
}
|
||||
w.Header().Add("Content-Length",
|
||||
strconv.FormatInt(int64(selectedLength), 10))
|
||||
_, wErr := w.Write(selectedData)
|
||||
return wErr
|
||||
}
|
||||
|
||||
func serveStaticPage(
|
||||
dataName string,
|
||||
code int,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
l log.Logger,
|
||||
) error {
|
||||
d, dFound := staticPages[dataName]
|
||||
if !dFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
selectedData := d.data
|
||||
selectedLength := len(d.data)
|
||||
compressEnabled := false
|
||||
if clientSupportGZIP(r) && d.hasCompressed() {
|
||||
selectedData = d.compressd
|
||||
selectedLength = len(d.compressd)
|
||||
compressEnabled = true
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
}
|
||||
w.Header().Add("Content-Type", d.contentType)
|
||||
if compressEnabled {
|
||||
w.Header().Add("Content-Encoding", "gzip")
|
||||
}
|
||||
w.Header().Add("Content-Length",
|
||||
strconv.FormatInt(int64(selectedLength), 10))
|
||||
w.WriteHeader(code)
|
||||
_, wErr := w.Write(selectedData)
|
||||
return wErr
|
||||
}
|
||||
414
application/controller/static_page_generater/main.go
Normal file
414
application/controller/static_page_generater/main.go
Normal file
@ -0,0 +1,414 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
parentPackage = "github.com/nirui/sshwifty/application/controller"
|
||||
)
|
||||
|
||||
const (
|
||||
staticListHeader = `// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
`
|
||||
|
||||
staticListTemplate = `import "time"
|
||||
|
||||
var (
|
||||
staticPages = map[string]staticData{
|
||||
{{ range . }}"{{ .Name }}":
|
||||
parseStaticData({{ .GOPackage }}.{{ .GOVariableName }}()),
|
||||
{{ end }}
|
||||
}
|
||||
)
|
||||
|
||||
// parseStaticData parses result from a static file returner and generate
|
||||
// a new ` + "`" + `staticData` + "`" + ` item
|
||||
func parseStaticData(
|
||||
fileStart int,
|
||||
fileEnd int,
|
||||
compressedStart int,
|
||||
compressedEnd int,
|
||||
creation time.Time,
|
||||
data []byte,
|
||||
contentType string,
|
||||
) staticData {
|
||||
return staticData{
|
||||
data: data[fileStart:fileEnd],
|
||||
compressd: data[compressedStart:compressedEnd],
|
||||
created: creation,
|
||||
contentType: contentType,
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
staticListTemplateDev = `import "io/ioutil"
|
||||
import "bytes"
|
||||
import "fmt"
|
||||
import "compress/gzip"
|
||||
import "time"
|
||||
import "mime"
|
||||
import "strings"
|
||||
|
||||
// WARNING: THIS GENERATION IS FOR DEBUG / DEVELOPMENT ONLY, DO NOT
|
||||
// USE IT IN PRODUCTION!
|
||||
|
||||
func getMimeTypeByExtension(ext string) string {
|
||||
switch ext {
|
||||
case ".ico":
|
||||
return "image/x-icon"
|
||||
case ".md":
|
||||
return "text/markdown"
|
||||
default:
|
||||
return mime.TypeByExtension(ext)
|
||||
}
|
||||
}
|
||||
|
||||
func staticFileGen(fileName, filePath string) staticData {
|
||||
content, readErr := ioutil.ReadFile(filePath)
|
||||
if readErr != nil {
|
||||
panic(fmt.Sprintln("Cannot read file:", readErr))
|
||||
}
|
||||
compressed := bytes.NewBuffer(make([]byte, 0, 1024))
|
||||
compresser, compresserBuildErr := gzip.NewWriterLevel(
|
||||
compressed, gzip.BestSpeed)
|
||||
if compresserBuildErr != nil {
|
||||
panic(fmt.Sprintln("Cannot build data compresser:", compresserBuildErr))
|
||||
}
|
||||
contentLen := len(content)
|
||||
_, compressErr := compresser.Write(content)
|
||||
if compressErr != nil {
|
||||
panic(fmt.Sprintln("Cannot write compressed data:", compressErr))
|
||||
}
|
||||
compressErr = compresser.Flush()
|
||||
if compressErr != nil {
|
||||
panic(fmt.Sprintln("Cannot write compressed data:", compressErr))
|
||||
}
|
||||
content = append(content, compressed.Bytes()...)
|
||||
fileExtDotIdx := strings.LastIndex(fileName, ".")
|
||||
fileExt := ""
|
||||
if fileExtDotIdx >= 0 {
|
||||
fileExt = fileName[fileExtDotIdx:len(fileName)]
|
||||
}
|
||||
mimeType := getMimeTypeByExtension(fileExt)
|
||||
if len(mimeType) <= 0 {
|
||||
mimeType = "application/binary"
|
||||
}
|
||||
return staticData{
|
||||
data: content[0:contentLen],
|
||||
contentType: mimeType,
|
||||
compressd: content[contentLen:],
|
||||
created: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
staticPages = map[string]staticData{
|
||||
{{ range . }}"{{ .Name }}": staticFileGen(
|
||||
"{{ .Name }}", "{{ .Path }}",
|
||||
),
|
||||
{{ end }}
|
||||
}
|
||||
)`
|
||||
|
||||
staticPageTemplate = `package {{ .GOPackage }}
|
||||
|
||||
// This file is part of Sshwifty Project
|
||||
//
|
||||
// Copyright (C) {{ .Date.Year }} Ni Rui (ranqus@gmail.com)
|
||||
//
|
||||
// https://github.com/nirui/sshwifty
|
||||
//
|
||||
// This file is generated at {{ .Date.Format "Mon, 02 Jan 2006 15:04:05 MST" }}
|
||||
// by "go generate", DO NOT EDIT! Also, do not open this file, it maybe too large
|
||||
// for your editor. You've been warned.
|
||||
//
|
||||
// This file may contain third-party binaries. See DEPENDENCIES.md for detail.
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// {{ .GOVariableName }} returns static file
|
||||
func {{ .GOVariableName }}() (
|
||||
int, // FileStart
|
||||
int, // FileEnd
|
||||
int, // CompressedStart
|
||||
int, // CompressedEnd
|
||||
time.Time, // Time of creation
|
||||
[]byte, // Data
|
||||
string, // ContentType
|
||||
) {
|
||||
created, createErr := time.Parse(
|
||||
time.RFC1123, "{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 MST" }}")
|
||||
if createErr != nil {
|
||||
panic(createErr)
|
||||
}
|
||||
return {{ .FileStart }}, {{ .FileEnd }},
|
||||
{{ .CompressedStart }}, {{ .CompressedEnd }},
|
||||
created, []byte({{ .Data }}), "{{ .ContentType }}"
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
const (
|
||||
templateStarts = "//go:generate"
|
||||
)
|
||||
|
||||
type parsedFile struct {
|
||||
Name string
|
||||
GOVariableName string
|
||||
GOFileName string
|
||||
GOPackage string
|
||||
Path string
|
||||
Data string
|
||||
Type string
|
||||
FileStart int
|
||||
FileEnd int
|
||||
CompressedStart int
|
||||
CompressedEnd int
|
||||
ContentType string
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
func buildListFile(w io.Writer, data interface{}) error {
|
||||
tpl := template.Must(template.New(
|
||||
"StaticPageList").Parse(staticListTemplate))
|
||||
return tpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func buildListFileDev(w io.Writer, data interface{}) error {
|
||||
tpl := template.Must(template.New(
|
||||
"StaticPageList").Parse(staticListTemplateDev))
|
||||
return tpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func buildDataFile(w io.Writer, data interface{}) error {
|
||||
tpl := template.Must(template.New(
|
||||
"StaticPageData").Parse(staticPageTemplate))
|
||||
return tpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func byteToQuotedString(b []byte) string {
|
||||
return fmt.Sprintf("%q", b)
|
||||
}
|
||||
|
||||
func getMimeTypeByExtension(ext string) string {
|
||||
switch ext {
|
||||
case ".ico":
|
||||
return "image/x-icon"
|
||||
case ".md":
|
||||
return "text/markdown"
|
||||
case ".map":
|
||||
return "text/plain"
|
||||
case ".txt":
|
||||
return "text/plain"
|
||||
case ".woff":
|
||||
return "application/font-woff"
|
||||
case ".woff2":
|
||||
return "application/font-woff2"
|
||||
default:
|
||||
return mime.TypeByExtension(ext)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFile(
|
||||
id int, name string, filePath string, packageName string) parsedFile {
|
||||
content, readErr := ioutil.ReadFile(filePath)
|
||||
if readErr != nil {
|
||||
panic(fmt.Sprintln("Cannot read file:", readErr))
|
||||
}
|
||||
contentLen := len(content)
|
||||
fileExtDotIdx := strings.LastIndex(name, ".")
|
||||
fileExt := ""
|
||||
if fileExtDotIdx >= 0 {
|
||||
fileExt = name[fileExtDotIdx:]
|
||||
}
|
||||
mimeType := getMimeTypeByExtension(fileExt)
|
||||
if len(mimeType) <= 0 {
|
||||
mimeType = "application/binary"
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
// Don't compress images
|
||||
} else if strings.HasPrefix(mimeType, "application/font-woff") {
|
||||
// Don't compress web fonts
|
||||
} else if mimeType == "text/plain" {
|
||||
// Don't compress plain text
|
||||
} else {
|
||||
compressed := bytes.NewBuffer(make([]byte, 0, 1024))
|
||||
compresser, compresserBuildErr := gzip.NewWriterLevel(
|
||||
compressed, gzip.BestCompression)
|
||||
if compresserBuildErr != nil {
|
||||
panic(fmt.Sprintln(
|
||||
"Cannot build data compresser:", compresserBuildErr))
|
||||
}
|
||||
_, compressErr := compresser.Write(content)
|
||||
if compressErr != nil {
|
||||
panic(fmt.Sprintln("Cannot write compressed data:", compressErr))
|
||||
}
|
||||
compressErr = compresser.Flush()
|
||||
if compressErr != nil {
|
||||
panic(fmt.Sprintln("Cannot write compressed data:", compressErr))
|
||||
}
|
||||
content = append(content, compressed.Bytes()...)
|
||||
}
|
||||
goFileName := "Static" + strconv.FormatInt(int64(id), 10)
|
||||
return parsedFile{
|
||||
Name: name,
|
||||
GOVariableName: strings.Title(goFileName),
|
||||
GOFileName: strings.ToLower(goFileName) + "_generated.go",
|
||||
GOPackage: packageName,
|
||||
Path: filePath,
|
||||
Data: byteToQuotedString(content),
|
||||
FileStart: 0,
|
||||
FileEnd: contentLen,
|
||||
CompressedStart: contentLen,
|
||||
CompressedEnd: len(content),
|
||||
ContentType: mimeType,
|
||||
Date: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
panic("Usage: <Source Folder> <(Destination) List File>")
|
||||
}
|
||||
sourcePath, sourcePathErr := filepath.Abs(os.Args[1])
|
||||
if sourcePathErr != nil {
|
||||
panic(fmt.Sprintf("Invalid source folder path %s: %s",
|
||||
os.Args[1], sourcePathErr))
|
||||
}
|
||||
listFilePath, listFilePathErr := filepath.Abs(os.Args[2])
|
||||
if listFilePathErr != nil {
|
||||
panic(fmt.Sprintf("Invalid destination list file path %s: %s",
|
||||
os.Args[2], listFilePathErr))
|
||||
}
|
||||
listFileName := filepath.Base(listFilePath)
|
||||
destFolderPackage := strings.TrimSuffix(
|
||||
listFileName, filepath.Ext(listFileName))
|
||||
destFolderPath := filepath.Join(
|
||||
filepath.Dir(listFilePath), destFolderPackage)
|
||||
destFolderPathErr := os.RemoveAll(destFolderPath)
|
||||
if destFolderPathErr != nil {
|
||||
panic(fmt.Sprintf("Unable to remove data destination folder %s: %s",
|
||||
destFolderPath, destFolderPathErr))
|
||||
}
|
||||
destFolderPathErr = os.Mkdir(destFolderPath, 0777)
|
||||
if destFolderPathErr != nil {
|
||||
panic(fmt.Sprintf("Unable to build data destination folder %s: %s",
|
||||
destFolderPath, destFolderPathErr))
|
||||
}
|
||||
listFile, listFileErr := os.Create(listFilePath)
|
||||
if listFileErr != nil {
|
||||
panic(fmt.Sprintf("Unable to open destination list file %s: %s",
|
||||
listFilePath, listFileErr))
|
||||
}
|
||||
defer listFile.Close()
|
||||
files, dirOpenErr := ioutil.ReadDir(sourcePath)
|
||||
if dirOpenErr != nil {
|
||||
panic(fmt.Sprintf("Unable to open dir: %s", dirOpenErr))
|
||||
}
|
||||
listFile.WriteString(staticListHeader)
|
||||
listFile.WriteString("\n// This file is generated by `go generate` at " +
|
||||
time.Now().Format(time.RFC1123) + "\n// DO NOT EDIT!\n\n")
|
||||
switch os.Getenv("NODE_ENV") {
|
||||
case "development":
|
||||
type sourceFiles struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
var sources []sourceFiles
|
||||
for f := range files {
|
||||
if !files[f].Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, sourceFiles{
|
||||
Name: files[f].Name(),
|
||||
Path: filepath.Join(sourcePath, files[f].Name()),
|
||||
})
|
||||
}
|
||||
tempBuildErr := buildListFileDev(listFile, sources)
|
||||
if tempBuildErr != nil {
|
||||
panic(fmt.Sprintf(
|
||||
"Unable to build destination file due to error: %s",
|
||||
tempBuildErr))
|
||||
}
|
||||
default:
|
||||
var parsedFiles []parsedFile
|
||||
for f := range files {
|
||||
if !files[f].Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
currentFilePath := filepath.Join(sourcePath, files[f].Name())
|
||||
parsedFiles = append(parsedFiles, parseFile(
|
||||
f, files[f].Name(), currentFilePath, destFolderPackage))
|
||||
}
|
||||
for f := range parsedFiles {
|
||||
fn := filepath.Join(destFolderPath, parsedFiles[f].GOFileName)
|
||||
ff, ffErr := os.Create(fn)
|
||||
if ffErr != nil {
|
||||
panic(fmt.Sprintf("Unable to create static page file %s: %s",
|
||||
fn, ffErr))
|
||||
}
|
||||
bErr := buildDataFile(ff, parsedFiles[f])
|
||||
if bErr != nil {
|
||||
panic(fmt.Sprintf("Unable to build static page file %s: %s",
|
||||
fn, bErr))
|
||||
}
|
||||
}
|
||||
listFile.WriteString(
|
||||
"\nimport \"" + parentPackage + "/" + destFolderPackage + "\"\n")
|
||||
tempBuildErr := buildListFile(listFile, parsedFiles)
|
||||
if tempBuildErr != nil {
|
||||
panic(fmt.Sprintf(
|
||||
"Unable to build destination file due to error: %s",
|
||||
tempBuildErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
48
application/log/ditch.go
Normal file
48
application/log/ditch.go
Normal file
@ -0,0 +1,48 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package log
|
||||
|
||||
// Ditch ditch all logs
|
||||
type Ditch struct{}
|
||||
|
||||
// NewDitch creates a new Ditch
|
||||
func NewDitch() Ditch {
|
||||
return Ditch{}
|
||||
}
|
||||
|
||||
// Context build a new Sub context
|
||||
func (w Ditch) Context(name string, params ...interface{}) Logger {
|
||||
return w
|
||||
}
|
||||
|
||||
// Write writes default error
|
||||
func (w Ditch) Write(b []byte) (int, error) {
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// Info write an info message
|
||||
func (w Ditch) Info(msg string, params ...interface{}) {}
|
||||
|
||||
// Debug write an debug message
|
||||
func (w Ditch) Debug(msg string, params ...interface{}) {}
|
||||
|
||||
// Warning write an warning message
|
||||
func (w Ditch) Warning(msg string, params ...interface{}) {}
|
||||
|
||||
// Error write an error message
|
||||
func (w Ditch) Error(msg string, params ...interface{}) {}
|
||||
28
application/log/log.go
Normal file
28
application/log/log.go
Normal file
@ -0,0 +1,28 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package log
|
||||
|
||||
// Logger represents a logger
|
||||
type Logger interface {
|
||||
Context(name string, params ...interface{}) Logger
|
||||
Write(b []byte) (int, error)
|
||||
Info(msg string, params ...interface{})
|
||||
Debug(msg string, params ...interface{})
|
||||
Warning(msg string, params ...interface{})
|
||||
Error(msg string, params ...interface{})
|
||||
}
|
||||
80
application/log/writer.go
Normal file
80
application/log/writer.go
Normal file
@ -0,0 +1,80 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Writer will write logs to the underlaying writer
|
||||
type Writer struct {
|
||||
c string
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewWriter creates a new Writer
|
||||
func NewWriter(context string, w io.Writer) Writer {
|
||||
return Writer{
|
||||
c: context,
|
||||
w: w,
|
||||
}
|
||||
}
|
||||
|
||||
// Context build a new Sub context
|
||||
func (w Writer) Context(name string, params ...interface{}) Logger {
|
||||
return NewWriter(w.c+" > "+fmt.Sprintf(name, params...), w.w)
|
||||
}
|
||||
|
||||
// Write writes default error
|
||||
func (w Writer) Write(b []byte) (int, error) {
|
||||
_, wErr := w.write("DEF", string(b))
|
||||
|
||||
if wErr != nil {
|
||||
return 0, wErr
|
||||
}
|
||||
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (w Writer) write(
|
||||
prefix string, msg string, params ...interface{}) (int, error) {
|
||||
return fmt.Fprintf(w.w, "["+prefix+"] "+
|
||||
time.Now().Format(time.RFC1123)+" "+w.c+": "+msg+"\r\n", params...)
|
||||
}
|
||||
|
||||
// Info write an info message
|
||||
func (w Writer) Info(msg string, params ...interface{}) {
|
||||
w.write("INF", msg, params...)
|
||||
}
|
||||
|
||||
// Debug write an debug message
|
||||
func (w Writer) Debug(msg string, params ...interface{}) {
|
||||
w.write("DBG", msg, params...)
|
||||
}
|
||||
|
||||
// Warning write an warning message
|
||||
func (w Writer) Warning(msg string, params ...interface{}) {
|
||||
w.write("WRN", msg, params...)
|
||||
}
|
||||
|
||||
// Error write an error message
|
||||
func (w Writer) Error(msg string, params ...interface{}) {
|
||||
w.write("ERR", msg, params...)
|
||||
}
|
||||
54
application/log/writer_nodebug.go
Normal file
54
application/log/writer_nodebug.go
Normal file
@ -0,0 +1,54 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// NonDebugWriter will write logs to the underlaying writer
|
||||
type NonDebugWriter struct {
|
||||
Writer
|
||||
}
|
||||
|
||||
// NewNonDebugWriter creates a new Writer with debug output disabled
|
||||
func NewNonDebugWriter(context string, w io.Writer) NonDebugWriter {
|
||||
return NonDebugWriter{
|
||||
Writer: NewWriter(context, w),
|
||||
}
|
||||
}
|
||||
|
||||
// NewDebugOrNonDebugWriter creates debug or nondebug log depends on
|
||||
// given `useDebug`
|
||||
func NewDebugOrNonDebugWriter(
|
||||
useDebug bool, context string, w io.Writer) Logger {
|
||||
if useDebug {
|
||||
return NewWriter(context, w)
|
||||
}
|
||||
|
||||
return NewNonDebugWriter(context, w)
|
||||
}
|
||||
|
||||
// Context build a new Sub context
|
||||
func (w NonDebugWriter) Context(name string, params ...interface{}) Logger {
|
||||
return NewNonDebugWriter(w.c+" > "+fmt.Sprintf(name, params...), w.w)
|
||||
}
|
||||
|
||||
// Debug ditchs debug operation
|
||||
func (w NonDebugWriter) Debug(msg string, params ...interface{}) {}
|
||||
26
application/network/conn.go
Normal file
26
application/network/conn.go
Normal file
@ -0,0 +1,26 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
emptyTime = time.Time{}
|
||||
)
|
||||
221
application/network/conn_timeout.go
Normal file
221
application/network/conn_timeout.go
Normal file
@ -0,0 +1,221 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeoutConn read write
|
||||
type TimeoutConn struct {
|
||||
net.Conn
|
||||
|
||||
readTimeout time.Duration
|
||||
disableNextReadTimeout bool
|
||||
writeTimeout time.Duration
|
||||
disableNextWriteTimeout bool
|
||||
}
|
||||
|
||||
// NewTimeoutConn creates a new TimeoutConn
|
||||
func NewTimeoutConn(
|
||||
c net.Conn,
|
||||
rTimeout time.Duration,
|
||||
wTimeout time.Duration,
|
||||
) TimeoutConn {
|
||||
return TimeoutConn{
|
||||
Conn: c,
|
||||
readTimeout: rTimeout,
|
||||
disableNextReadTimeout: false,
|
||||
writeTimeout: wTimeout,
|
||||
disableNextWriteTimeout: false,
|
||||
}
|
||||
}
|
||||
|
||||
// SetReadTimeout sets read timeout
|
||||
func (c *TimeoutConn) SetReadTimeout(t time.Duration) {
|
||||
c.readTimeout = t
|
||||
}
|
||||
|
||||
// SetReadDeadline sets the next read deadline
|
||||
func (c *TimeoutConn) SetReadDeadline(t time.Time) error {
|
||||
c.disableNextReadTimeout = t.Before(time.Now())
|
||||
|
||||
if t.Equal(emptyTime) {
|
||||
return c.Conn.SetReadDeadline(time.Now().Add(c.readTimeout))
|
||||
}
|
||||
|
||||
return c.Conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
// Read reads data
|
||||
func (c *TimeoutConn) Read(b []byte) (int, error) {
|
||||
defer func() {
|
||||
c.disableNextReadTimeout = false
|
||||
}()
|
||||
|
||||
cLen, cErr := c.Conn.Read(b)
|
||||
|
||||
if cErr == nil {
|
||||
return cLen, nil
|
||||
}
|
||||
|
||||
netErr, isNetErr := cErr.(net.Error)
|
||||
|
||||
if !isNetErr ||
|
||||
c.disableNextReadTimeout ||
|
||||
c.readTimeout <= 0 ||
|
||||
!netErr.Timeout() {
|
||||
return cLen, cErr
|
||||
}
|
||||
|
||||
cErr = c.Conn.SetReadDeadline(time.Now().Add(c.readTimeout))
|
||||
|
||||
if cErr != nil {
|
||||
return cLen, cErr
|
||||
}
|
||||
|
||||
tryCLen, cErr := c.Conn.Read(b[cLen:])
|
||||
|
||||
return tryCLen + cLen, cErr
|
||||
}
|
||||
|
||||
// SetWriteTimeout sets write timeout
|
||||
func (c *TimeoutConn) SetWriteTimeout(t time.Duration) {
|
||||
c.writeTimeout = t
|
||||
}
|
||||
|
||||
// SetWriteDeadline sets the next read deadline
|
||||
func (c *TimeoutConn) SetWriteDeadline(t time.Time) error {
|
||||
c.disableNextWriteTimeout = t.Before(time.Now())
|
||||
|
||||
if t.Equal(emptyTime) {
|
||||
return c.Conn.SetWriteDeadline(time.Now().Add(c.writeTimeout))
|
||||
}
|
||||
|
||||
return c.Conn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
// Write writes data
|
||||
func (c *TimeoutConn) Write(b []byte) (int, error) {
|
||||
defer func() {
|
||||
c.disableNextWriteTimeout = false
|
||||
}()
|
||||
|
||||
cLen, cErr := c.Conn.Write(b)
|
||||
|
||||
if cErr == nil {
|
||||
return cLen, nil
|
||||
}
|
||||
|
||||
netErr, isNetErr := cErr.(net.Error)
|
||||
|
||||
if !isNetErr ||
|
||||
c.disableNextWriteTimeout ||
|
||||
c.writeTimeout <= 0 ||
|
||||
!netErr.Timeout() {
|
||||
return cLen, cErr
|
||||
}
|
||||
|
||||
cErr = c.Conn.SetWriteDeadline(time.Now().Add(c.writeTimeout))
|
||||
|
||||
if cErr != nil {
|
||||
return cLen, cErr
|
||||
}
|
||||
|
||||
tryCLen, cErr := c.Conn.Write(b[cLen:])
|
||||
|
||||
return tryCLen + cLen, cErr
|
||||
}
|
||||
|
||||
// SetDeadline sets read and write deadline
|
||||
func (c *TimeoutConn) SetDeadline(t time.Time) error {
|
||||
c.SetReadDeadline(t)
|
||||
c.SetWriteDeadline(t)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadTimeoutConn is a reader that will enforce a timeout rules
|
||||
type ReadTimeoutConn struct {
|
||||
net.Conn
|
||||
|
||||
reader TimeoutConn
|
||||
}
|
||||
|
||||
// NewReadTimeoutConn creates a ReadTimeoutConn
|
||||
func NewReadTimeoutConn(c net.Conn, timeout time.Duration) ReadTimeoutConn {
|
||||
return ReadTimeoutConn{
|
||||
Conn: c,
|
||||
reader: TimeoutConn{
|
||||
Conn: c,
|
||||
readTimeout: timeout,
|
||||
writeTimeout: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetReadDeadline sets read deadline
|
||||
func (c *ReadTimeoutConn) SetReadDeadline(t time.Time) error {
|
||||
return c.reader.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
// SetReadTimeout sets write timeout
|
||||
func (c *ReadTimeoutConn) SetReadTimeout(t time.Duration) {
|
||||
c.reader.SetReadTimeout(t)
|
||||
}
|
||||
|
||||
// Read writes data
|
||||
func (c ReadTimeoutConn) Read(b []byte) (int, error) {
|
||||
return c.reader.Read(b)
|
||||
}
|
||||
|
||||
// WriteTimeoutConn is a writer that will enforce a timeout rules onto a
|
||||
// net.Conn
|
||||
type WriteTimeoutConn struct {
|
||||
net.Conn
|
||||
|
||||
writer TimeoutConn
|
||||
}
|
||||
|
||||
// NewWriteTimeoutConn creates a WriteTimeoutConnWriter
|
||||
func NewWriteTimeoutConn(c net.Conn, timeout time.Duration) WriteTimeoutConn {
|
||||
return WriteTimeoutConn{
|
||||
Conn: c,
|
||||
writer: TimeoutConn{
|
||||
Conn: c,
|
||||
readTimeout: 0,
|
||||
writeTimeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetWriteDeadline sets write deadline
|
||||
func (c *WriteTimeoutConn) SetWriteDeadline(t time.Time) error {
|
||||
return c.writer.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
// SetWriteTimeout sets write timeout
|
||||
func (c *WriteTimeoutConn) SetWriteTimeout(t time.Duration) {
|
||||
c.writer.SetWriteTimeout(t)
|
||||
}
|
||||
|
||||
// Write writes data
|
||||
func (c WriteTimeoutConn) Write(b []byte) (int, error) {
|
||||
return c.writer.Write(b)
|
||||
}
|
||||
38
application/network/dial.go
Normal file
38
application/network/dial.go
Normal file
@ -0,0 +1,38 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Dial dial to remote machine
|
||||
type Dial func(
|
||||
network string, address string, timeout time.Duration) (net.Conn, error)
|
||||
|
||||
// TCPDial build a TCP dialer
|
||||
func TCPDial() Dial {
|
||||
return func(
|
||||
network string,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
) (net.Conn, error) {
|
||||
return net.DialTimeout(network, address, timeout)
|
||||
}
|
||||
}
|
||||
60
application/network/dial_ac.go
Normal file
60
application/network/dial_ac.go
Normal file
@ -0,0 +1,60 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrAccessControlDialTargetHostNotAllowed = errors.New(
|
||||
"unable to dial to the specified remote host due to 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)
|
||||
}
|
||||
}
|
||||
94
application/network/dial_socks5.go
Normal file
94
application/network/dial_socks5.go
Normal file
@ -0,0 +1,94 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type socks5Dial struct {
|
||||
net.Dialer
|
||||
}
|
||||
|
||||
func (s socks5Dial) Dial(
|
||||
network, address string) (net.Conn, error) {
|
||||
conn, dErr := s.Dialer.Dial(network, address)
|
||||
|
||||
if dErr == nil {
|
||||
conn.SetReadDeadline(time.Now().Add(s.Dialer.Timeout))
|
||||
}
|
||||
|
||||
return conn, dErr
|
||||
}
|
||||
|
||||
func (s socks5Dial) DialContext(
|
||||
ctx context.Context, network, address string) (net.Conn, error) {
|
||||
conn, dErr := s.Dialer.DialContext(ctx, network, address)
|
||||
|
||||
if dErr == nil {
|
||||
conn.SetReadDeadline(time.Now().Add(s.Dialer.Timeout))
|
||||
}
|
||||
|
||||
return conn, dErr
|
||||
}
|
||||
|
||||
// BuildSocks5Dial builds a Socks5 dialer
|
||||
func BuildSocks5Dial(
|
||||
socks5Address string, userName string, password string) (Dial, error) {
|
||||
var auth *proxy.Auth
|
||||
|
||||
if len(userName) > 0 || len(password) > 0 {
|
||||
auth = &proxy.Auth{
|
||||
User: userName,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
return func(
|
||||
network string,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
) (net.Conn, error) {
|
||||
dialCfg := socks5Dial{
|
||||
Dialer: net.Dialer{
|
||||
Timeout: timeout,
|
||||
Deadline: time.Now().Add(timeout),
|
||||
},
|
||||
}
|
||||
|
||||
dial, dialErr := proxy.SOCKS5("tcp", socks5Address, auth, &dialCfg)
|
||||
|
||||
if dialErr != nil {
|
||||
return nil, dialErr
|
||||
}
|
||||
|
||||
dialConn, dialErr := dial.Dial(network, address)
|
||||
|
||||
if dialErr != nil {
|
||||
return nil, dialErr
|
||||
}
|
||||
|
||||
dialConn.SetReadDeadline(emptyTime)
|
||||
|
||||
return dialConn, nil
|
||||
}, nil
|
||||
}
|
||||
36
application/plate.go
Normal file
36
application/plate.go
Normal file
@ -0,0 +1,36 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package application
|
||||
|
||||
// Plate information
|
||||
const (
|
||||
Name = "Sshwifty"
|
||||
FullName = "Sshwifty Web SSH Client"
|
||||
Author = "Ni Rui <ranqus@gmail.com>"
|
||||
URL = "https://github.com/nirui/sshwifty"
|
||||
)
|
||||
|
||||
// Banner message
|
||||
const (
|
||||
banner = "\r\n %s %s\r\n\r\n Copyright (C) %s\r\n %s\r\n\r\n"
|
||||
)
|
||||
|
||||
// Version
|
||||
var (
|
||||
version = "dev"
|
||||
)
|
||||
146
application/rw/fetch.go
Normal file
146
application/rw/fetch.go
Normal file
@ -0,0 +1,146 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package rw
|
||||
|
||||
import "errors"
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrFetchReaderNotEnoughBuffer = errors.New(
|
||||
"not enough buffer")
|
||||
)
|
||||
|
||||
// FetchReaderFetcher generates data for SourceReader
|
||||
type FetchReaderFetcher func() ([]byte, error)
|
||||
|
||||
// FetchReader read from the source and increase your lifespan if used correctly
|
||||
type FetchReader struct {
|
||||
source FetchReaderFetcher // Source data fetcher
|
||||
data []byte // Fetched source data
|
||||
dataUsed int // Used source data
|
||||
}
|
||||
|
||||
// Fetch fetchs
|
||||
type Fetch func(n int) ([]byte, error)
|
||||
|
||||
// FetchOneByte fetchs one byte from the Fetch, or return an error when it fails
|
||||
func FetchOneByte(f Fetch) ([]byte, error) {
|
||||
for {
|
||||
d, dErr := f(1)
|
||||
|
||||
if dErr != nil {
|
||||
return nil, dErr
|
||||
}
|
||||
|
||||
if len(d) <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewFetchReader creates a new FetchReader
|
||||
func NewFetchReader(g FetchReaderFetcher) FetchReader {
|
||||
return FetchReader{
|
||||
source: g,
|
||||
data: nil,
|
||||
dataUsed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r FetchReader) dataRemain() int {
|
||||
return len(r.data) - r.dataUsed
|
||||
}
|
||||
|
||||
// Remain Returns how many bytes is waiting to be readed
|
||||
func (r *FetchReader) Remain() int {
|
||||
return r.dataRemain()
|
||||
}
|
||||
|
||||
// Export directly exports from buffer, never read from source
|
||||
//
|
||||
// Params:
|
||||
// - n: Exact amount of bytes to fetch (0 to n, n included). If number n is
|
||||
// unreachable, an error will be returned, and no internal status will
|
||||
// be changed
|
||||
//
|
||||
// Returns:
|
||||
// - Fetched data
|
||||
// - Read error
|
||||
func (r *FetchReader) Export(n int) ([]byte, error) {
|
||||
remain := r.dataRemain()
|
||||
|
||||
if n > remain {
|
||||
return nil, ErrFetchReaderNotEnoughBuffer
|
||||
}
|
||||
|
||||
newUsed := r.dataUsed + n
|
||||
data := r.data[r.dataUsed:newUsed]
|
||||
|
||||
r.dataUsed = newUsed
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Fetch fetchs data from the source
|
||||
//
|
||||
// Params:
|
||||
// - n: Max bytes to fetch (0 to n, n included)
|
||||
//
|
||||
// Returns:
|
||||
// - Fetched data
|
||||
// - Read error
|
||||
func (r *FetchReader) Fetch(n int) ([]byte, error) {
|
||||
remain := r.dataRemain()
|
||||
|
||||
if remain <= 0 {
|
||||
data, dataFetchErr := r.source()
|
||||
|
||||
if dataFetchErr != nil {
|
||||
return nil, dataFetchErr
|
||||
}
|
||||
|
||||
r.data = data
|
||||
r.dataUsed = 0
|
||||
|
||||
remain = r.dataRemain()
|
||||
}
|
||||
|
||||
if n > remain {
|
||||
n = remain
|
||||
}
|
||||
|
||||
newUsed := r.dataUsed + n
|
||||
data := r.data[r.dataUsed:newUsed]
|
||||
|
||||
r.dataUsed = newUsed
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Read implements io.Read
|
||||
func (r *FetchReader) Read(b []byte) (int, error) {
|
||||
d, dErr := r.Fetch(len(b))
|
||||
|
||||
if dErr != nil {
|
||||
return 0, dErr
|
||||
}
|
||||
|
||||
return copy(b, d), nil
|
||||
}
|
||||
59
application/rw/fetch_test.go
Normal file
59
application/rw/fetch_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package rw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testDummyFetchGen(data []byte) FetchReaderFetcher {
|
||||
current := 0
|
||||
|
||||
return func() ([]byte, error) {
|
||||
if current >= len(data) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
oldCurrent := current
|
||||
current = oldCurrent + 1
|
||||
|
||||
return data[oldCurrent:current], nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReader(t *testing.T) {
|
||||
r := NewFetchReader(testDummyFetchGen([]byte("Hello World")))
|
||||
b := make([]byte, 11)
|
||||
|
||||
_, rErr := io.ReadFull(&r, b)
|
||||
|
||||
if rErr != nil {
|
||||
t.Error("Failed to read due to error:", rErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !bytes.Equal(b, []byte("Hello World")) {
|
||||
t.Errorf("Expecting data to be %s, got %s instead",
|
||||
[]byte("Hello World"), b)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
130
application/rw/limited.go
Normal file
130
application/rw/limited.go
Normal file
@ -0,0 +1,130 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package rw
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrReadUntilCompletedBufferFull = errors.New(
|
||||
"cannot read more, not enough data buffer")
|
||||
)
|
||||
|
||||
// LimitedReader reads only n bytes of data
|
||||
type LimitedReader struct {
|
||||
r *FetchReader
|
||||
n int
|
||||
}
|
||||
|
||||
// ReadUntilCompleted read until the reader is completed
|
||||
func ReadUntilCompleted(r *LimitedReader, b []byte) (int, error) {
|
||||
bCur := 0
|
||||
bLen := len(b)
|
||||
|
||||
for !r.Completed() {
|
||||
if bCur >= bLen {
|
||||
return bCur, ErrReadUntilCompletedBufferFull
|
||||
}
|
||||
|
||||
rLen, rErr := r.Read(b[bCur:])
|
||||
|
||||
if rErr != nil {
|
||||
return bCur + rLen, rErr
|
||||
}
|
||||
|
||||
bCur += rLen
|
||||
}
|
||||
|
||||
return bCur, nil
|
||||
}
|
||||
|
||||
// NewLimitedReader creates a new LimitedReader
|
||||
func NewLimitedReader(r *FetchReader, n int) LimitedReader {
|
||||
return LimitedReader{
|
||||
r: r,
|
||||
n: n,
|
||||
}
|
||||
}
|
||||
|
||||
// Buffered exports the internal buffer
|
||||
func (l *LimitedReader) Buffered() ([]byte, error) {
|
||||
return l.Fetch(l.Remains())
|
||||
}
|
||||
|
||||
// Fetch fetchs max n bytes from buffer
|
||||
func (l *LimitedReader) Fetch(n int) ([]byte, error) {
|
||||
if l.Completed() {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
if n > l.Remains() {
|
||||
n = l.Remains()
|
||||
}
|
||||
|
||||
exported, eErr := l.r.Fetch(n)
|
||||
|
||||
l.n -= len(exported)
|
||||
|
||||
return exported, eErr
|
||||
}
|
||||
|
||||
// Read read from the LimitedReader
|
||||
func (l *LimitedReader) Read(b []byte) (int, error) {
|
||||
if l.Completed() {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
toRead := len(b)
|
||||
|
||||
if toRead > l.Remains() {
|
||||
toRead = l.Remains()
|
||||
}
|
||||
|
||||
rLen, rErr := l.r.Read(b[:toRead])
|
||||
|
||||
l.n -= rLen
|
||||
|
||||
return rLen, rErr
|
||||
}
|
||||
|
||||
// Ditch ditchs all remaining data. Data will be written and overwritten to
|
||||
// the given buf when ditching
|
||||
func (l *LimitedReader) Ditch(buf []byte) error {
|
||||
for !l.Completed() {
|
||||
_, rErr := l.Read(buf)
|
||||
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remains returns how many bytes is waiting to be read
|
||||
func (l LimitedReader) Remains() int {
|
||||
return l.n
|
||||
}
|
||||
|
||||
// Completed returns whether or not current reader is completed
|
||||
func (l LimitedReader) Completed() bool {
|
||||
return l.n <= 0
|
||||
}
|
||||
41
application/rw/rw.go
Normal file
41
application/rw/rw.go
Normal file
@ -0,0 +1,41 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package rw
|
||||
|
||||
// ReaderFunc function of io.Reader
|
||||
type ReaderFunc func(b []byte) (int, error)
|
||||
|
||||
// ReadFull Read until given b is fully loaded
|
||||
func ReadFull(r ReaderFunc, b []byte) (int, error) {
|
||||
bLen := len(b)
|
||||
readed := 0
|
||||
|
||||
for {
|
||||
rLen, rErr := r(b[readed:])
|
||||
|
||||
readed += rLen
|
||||
|
||||
if rErr != nil {
|
||||
return readed, rErr
|
||||
}
|
||||
|
||||
if readed >= bLen {
|
||||
return readed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
85
application/server/conn.go
Normal file
85
application/server/conn.go
Normal file
@ -0,0 +1,85 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/network"
|
||||
)
|
||||
|
||||
type listener struct {
|
||||
*net.TCPListener
|
||||
|
||||
readTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
}
|
||||
|
||||
func (l listener) Accept() (net.Conn, error) {
|
||||
acc, accErr := l.TCPListener.Accept()
|
||||
|
||||
if accErr != nil {
|
||||
return nil, accErr
|
||||
}
|
||||
|
||||
timeoutConn := network.NewTimeoutConn(acc, l.readTimeout, l.writeTimeout)
|
||||
|
||||
return conn{
|
||||
TimeoutConn: &timeoutConn,
|
||||
readTimeout: l.readTimeout,
|
||||
writeTimeout: l.writeTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// conn is a net.Conn hack, we use it prevent the upper to alter some important
|
||||
// configuration of the connection, mainly the timeouts.
|
||||
type conn struct {
|
||||
*network.TimeoutConn
|
||||
|
||||
readTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
}
|
||||
|
||||
func (c conn) normalizeTimeout(t time.Time, m time.Duration) time.Time {
|
||||
max := time.Now().Add(m)
|
||||
|
||||
// You cannot set timeout that is longer than the given m
|
||||
if t.After(max) {
|
||||
return max
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (c conn) SetDeadline(dl time.Time) error {
|
||||
c.SetReadDeadline(dl)
|
||||
c.SetWriteDeadline(dl)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c conn) SetReadDeadline(dl time.Time) error {
|
||||
return c.TimeoutConn.SetReadDeadline(
|
||||
c.normalizeTimeout(dl, c.readTimeout))
|
||||
}
|
||||
|
||||
func (c conn) SetWriteDeadline(dl time.Time) error {
|
||||
return c.TimeoutConn.SetWriteDeadline(
|
||||
c.normalizeTimeout(dl, c.writeTimeout))
|
||||
}
|
||||
180
application/server/server.go
Normal file
180
application/server/server.go
Normal file
@ -0,0 +1,180 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
goLog "log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nirui/sshwifty/application/command"
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
type dumpWrite struct{}
|
||||
|
||||
func (d dumpWrite) Write(b []byte) (int, error) {
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrInvalidIPAddress = errors.New(
|
||||
"invalid IP address")
|
||||
)
|
||||
|
||||
// HandlerBuilder builds a HTTP handler
|
||||
type HandlerBuilder func(
|
||||
commonCfg configuration.Common,
|
||||
cfg configuration.Server,
|
||||
logger log.Logger) http.Handler
|
||||
|
||||
// HandlerBuilderBuilder builds HandlerBuilder
|
||||
type HandlerBuilderBuilder func(command.Commands) HandlerBuilder
|
||||
|
||||
// CloseCallback will be called when the server has closed
|
||||
type CloseCallback func(error)
|
||||
|
||||
// Server represents a server
|
||||
type Server struct {
|
||||
logger log.Logger
|
||||
shutdownWait *sync.WaitGroup
|
||||
}
|
||||
|
||||
// Serving represents a server that is serving for requests
|
||||
type Serving struct {
|
||||
server http.Server
|
||||
shutdownWait *sync.WaitGroup
|
||||
}
|
||||
|
||||
// New creates a new Server builder
|
||||
func New(logger log.Logger) Server {
|
||||
return Server{
|
||||
logger: logger,
|
||||
shutdownWait: &sync.WaitGroup{},
|
||||
}
|
||||
}
|
||||
|
||||
// Serve starts serving
|
||||
func (s Server) Serve(
|
||||
commonCfg configuration.Common,
|
||||
serverCfg configuration.Server,
|
||||
closeCallback CloseCallback,
|
||||
handlerBuilder HandlerBuilder,
|
||||
) *Serving {
|
||||
ssCfg := serverCfg.WithDefault()
|
||||
l := s.logger.Context(
|
||||
"Server (%s:%d)", ssCfg.ListenInterface, ssCfg.ListenPort)
|
||||
ss := &Serving{
|
||||
server: http.Server{
|
||||
Handler: handlerBuilder(commonCfg, ssCfg, l),
|
||||
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
ReadTimeout: ssCfg.ReadTimeout,
|
||||
ReadHeaderTimeout: ssCfg.InitialTimeout,
|
||||
WriteTimeout: ssCfg.WriteTimeout,
|
||||
IdleTimeout: ssCfg.ReadTimeout,
|
||||
MaxHeaderBytes: http.DefaultMaxHeaderBytes,
|
||||
ErrorLog: goLog.New(dumpWrite{}, "", 0),
|
||||
},
|
||||
shutdownWait: s.shutdownWait,
|
||||
}
|
||||
s.shutdownWait.Add(1)
|
||||
go ss.run(l, ssCfg, closeCallback)
|
||||
return ss
|
||||
}
|
||||
|
||||
// Wait waits until all server is closed
|
||||
func (s Server) Wait() {
|
||||
s.shutdownWait.Wait()
|
||||
}
|
||||
|
||||
func (s *Serving) buildListener(
|
||||
ip string,
|
||||
port uint16,
|
||||
readTimeout time.Duration,
|
||||
writeTimeout time.Duration,
|
||||
) (listener, error) {
|
||||
ipAddr := net.ParseIP(ip)
|
||||
if ipAddr == nil {
|
||||
return listener{}, ErrInvalidIPAddress
|
||||
}
|
||||
ipPort := net.JoinHostPort(
|
||||
ipAddr.String(), strconv.FormatInt(int64(port), 10))
|
||||
addr, addrErr := net.ResolveTCPAddr("tcp", ipPort)
|
||||
if addrErr != nil {
|
||||
return listener{}, addrErr
|
||||
}
|
||||
ll, llErr := net.ListenTCP("tcp", addr)
|
||||
if llErr != nil {
|
||||
return listener{}, llErr
|
||||
}
|
||||
return listener{
|
||||
TCPListener: ll,
|
||||
readTimeout: readTimeout,
|
||||
writeTimeout: writeTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// run starts the server
|
||||
func (s *Serving) run(
|
||||
logger log.Logger,
|
||||
cfg configuration.Server,
|
||||
closeCallback CloseCallback,
|
||||
) error {
|
||||
var err error
|
||||
defer func() {
|
||||
if err == nil || err == http.ErrServerClosed {
|
||||
logger.Info("Closed")
|
||||
} else {
|
||||
logger.Warning("Failed to serve due to error: %s", err)
|
||||
}
|
||||
s.shutdownWait.Done()
|
||||
closeCallback(err)
|
||||
}()
|
||||
ls, err := s.buildListener(
|
||||
cfg.ListenInterface,
|
||||
cfg.ListenPort,
|
||||
cfg.ReadTimeout,
|
||||
cfg.WriteTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ls.Close()
|
||||
if !cfg.IsTLS() {
|
||||
logger.Info("Serving")
|
||||
err = s.server.Serve(ls)
|
||||
} else {
|
||||
logger.Info("Serving TLS")
|
||||
err = s.server.ServeTLS(
|
||||
ls, cfg.TLSCertificateFile, cfg.TLSCertificateKeyFile)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Close close the server
|
||||
func (s *Serving) Close() error {
|
||||
return s.server.Shutdown(context.TODO())
|
||||
}
|
||||
25
babel.config.js
Normal file
25
babel.config.js
Normal file
@ -0,0 +1,25 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
return {
|
||||
presets: ["@babel/preset-env"],
|
||||
plugins: [["@babel/plugin-transform-runtime"]],
|
||||
};
|
||||
};
|
||||
73
docker-compose.yml
Normal file
73
docker-compose.yml
Normal file
@ -0,0 +1,73 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
# SSHWIFTY
|
||||
sshwifty:
|
||||
container_name: sshwifty
|
||||
user: "nobody:nobody"
|
||||
restart: always
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- 9133:8182
|
||||
deploy:
|
||||
labels:
|
||||
- traefik.frontend.rule=Host:ssh.legaragenumerique.fr
|
||||
- traefik.port=80
|
||||
- traefik.frontend.auth.forward.address=http://traefik-forward-auth:4181
|
||||
- traefik.frontend.auth.forward.authResponseHeaders=X-Forwarded-User
|
||||
- traefik.frontend.auth.forward.trustForwardHeader=true
|
||||
|
||||
# TRAEFIK
|
||||
traefik:
|
||||
image: traefik:v2.10
|
||||
container_name: traefik
|
||||
restart: always
|
||||
command:
|
||||
# For web ui traefik DEV
|
||||
# - "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.swarmmode=false"
|
||||
- "--log.level=DEBUG"
|
||||
- "--providers.docker.exposedByDefault=false"
|
||||
|
||||
- "--entryPoints.web.address=:80"
|
||||
# - "--entryPoints.websecure.address=:443"
|
||||
# - "--certificatesResolvers.le.acme.email=${ACME_EMAIL}"
|
||||
# - "--certificatesResolvers.le.acme.storage=/acme/acme.json"
|
||||
# - "--certificatesResolvers.le.acme.httpChallenge=true"
|
||||
# - "--certificatesResolvers.le.acme.httpChallenge.entryPoint=web"
|
||||
# - "--certificatesresolvers.le.acme.caserver=https://acme-v02.api.letsencrypt.org/directory"
|
||||
ports:
|
||||
# - "4443:443"
|
||||
- "8880:80"
|
||||
# The Web UI (enabled by --api.insecure=true) DEV
|
||||
# - "8082:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# - ./acme:/acme
|
||||
networks:
|
||||
traefik_net:
|
||||
|
||||
# GATE KEEPER
|
||||
traefik-forward-auth:
|
||||
image: funkypenguin/traefik-forward-auth
|
||||
restart: always
|
||||
env_file: ./traefik-forward-auth.env
|
||||
networks:
|
||||
- traefik_net
|
||||
deploy:
|
||||
labels:
|
||||
- traefik.port=4181
|
||||
- traefik.frontend.rule=Host:id/legaragenumerique.fr
|
||||
- traefik.frontend.auth.forward.address=http://traefik-forward-auth:4181
|
||||
- traefik.frontend.auth.forward.trustForwardHeader=true
|
||||
|
||||
|
||||
# NETWORKS
|
||||
networks:
|
||||
traefik_net:
|
||||
|
||||
# VOLUMES
|
||||
# volumes:
|
||||
# acme: {}
|
||||
28
go.mod
Normal file
28
go.mod
Normal file
@ -0,0 +1,28 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
module github.com/nirui/sshwifty
|
||||
|
||||
go 1.21.5
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
golang.org/x/crypto v0.16.0
|
||||
golang.org/x/net v0.19.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.15.0 // indirect
|
||||
170
go.sum
Normal file
170
go.sum
Normal file
@ -0,0 +1,170 @@
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220210151621-f4118a5b28e2 h1:XdAboW3BNMv9ocSCOk/u1MFioZGzCNkiJZ19v9Oe3Ig=
|
||||
golang.org/x/crypto v0.0.0-20220210151621-f4118a5b28e2/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 h1:SLP7Q4Di66FONjDJbCYrCRrh97focO6sLogHO7/g8F0=
|
||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
|
||||
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 h1:a5Yg6ylndHHYJqIPrdq0AhvR6KTvDTAvgBtaidhEevY=
|
||||
golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220924013350-4ba4fb4dd9e7 h1:WJywXQVIb56P2kAvXeMGTIgQ1ZHQxR60+F9dLsodECc=
|
||||
golang.org/x/crypto v0.0.0-20220924013350-4ba4fb4dd9e7/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE=
|
||||
golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4=
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 h1:NWy5+hlRbC7HK+PmcXVUmW1IMyFce7to56IUvhUFm7Y=
|
||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220617184016-355a448f1bc9 h1:Yqz/iviulwKwAREEeUd3nbBFn0XuyJqkoft2IlrvOhc=
|
||||
golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220708220712-1185a9018129 h1:vucSRfWwTsoXro7P+3Cjlr6flUMtzCwzlvkxEQtHHB0=
|
||||
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220811182439-13a9a731de15 h1:cik0bxZUSJVDyaHf1hZPSDsU8SZHGQZQMeueXCE7yBQ=
|
||||
golang.org/x/net v0.0.0-20220811182439-13a9a731de15/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI=
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1 h1:TWZxd/th7FbRSMret2MVQdlI8uT49QEtwZdvJrxjEHU=
|
||||
golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20220923203811-8be639271d50 h1:vKyz8L3zkd+xrMeIaBsQ/MNVPVFSffdaU3ZyYlBGFnI=
|
||||
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.13.0 h1:Nvo8UFsZ8X3BhAC9699Z1j7XQ3rsZnUUm7jfBEk1ueY=
|
||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220519141025-dcacdad47464 h1:MpIuURY70f0iKp/oooEFtB2oENcHITo/z1b6u41pKCw=
|
||||
golang.org/x/sys v0.0.0-20220519141025-dcacdad47464/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c h1:aFV+BgZ4svzjfabn8ERpuB4JI4N6/rdy1iusx77G3oU=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 h1:wM1k/lXfpc5HdkJJyW9GELpd8ERGdnh8sMGL6Gzq3Ho=
|
||||
golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc=
|
||||
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
12230
package-lock.json
generated
Normal file
12230
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
package.json
Normal file
68
package.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "sshwifty-ui",
|
||||
"version": "0.0.0",
|
||||
"description": "Sshwifty Web Front-end Project",
|
||||
"main": "",
|
||||
"devDependencies": {
|
||||
"@azurity/pure-nerd-font": "^3.0.1",
|
||||
"@babel/core": "^7.23.5",
|
||||
"@babel/eslint-parser": "^7.23.3",
|
||||
"@babel/plugin-transform-runtime": "^7.23.4",
|
||||
"@babel/preset-env": "^7.23.5",
|
||||
"@babel/register": "^7.22.15",
|
||||
"@babel/runtime": "^7.23.5",
|
||||
"@xterm/addon-fit": "^0.9.0-beta.1",
|
||||
"@xterm/addon-unicode11": "^0.7.0-beta.1",
|
||||
"@xterm/addon-web-links": "^0.10.0-beta.1",
|
||||
"@xterm/addon-webgl": "^0.17.0-beta.1",
|
||||
"@xterm/xterm": "^5.4.0-beta.1",
|
||||
"babel-loader": "^9.1.3",
|
||||
"buffer": "^6.0.3",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"css-minimizer-webpack-plugin": "^5.0.1",
|
||||
"cwebp-bin": "^8.0.0",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-vue": "^9.19.2",
|
||||
"eslint-webpack-plugin": "^4.0.1",
|
||||
"favicons": "^7.1.4",
|
||||
"fontfaceobserver": "^2.3.0",
|
||||
"hack-font": "^3.3.0",
|
||||
"html-loader": "^4.2.0",
|
||||
"html-webpack-plugin": "^5.5.3",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"image-minimizer-webpack-plugin": "^3.8.3",
|
||||
"imagemin": "^8.0.1",
|
||||
"imagemin-gifsicle": "^7.0.0",
|
||||
"imagemin-mozjpeg": "^10.0.0",
|
||||
"imagemin-pngquant": "^9.0.2",
|
||||
"imagemin-svgo": "^10.0.1",
|
||||
"imagemin-webp": "^8.0.0",
|
||||
"mini-css-extract-plugin": "^2.7.6",
|
||||
"mocha": "^10.2.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"prettier": "^3.1.0",
|
||||
"roboto-fontface": "^0.10.0",
|
||||
"style-loader": "^3.3.3",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"vue": "^2.6.14",
|
||||
"vue-loader": "^15.9.8",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-favicons": "^1.3.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development webpack --mode=development --config=webpack.config.js --watch",
|
||||
"clean": "rm .tmp/ -rf || true",
|
||||
"generate": "npm run clean && NODE_ENV=production webpack --mode=production --config=webpack.config.js",
|
||||
"build": "npm run generate && CGO_ENABLED=0 go build -ldflags \"-s -w -X github.com/nirui/sshwifty/application.version=$(git describe --always --dirty='*' --tag)\"",
|
||||
"lint": "eslint --ext .js,.vue ui",
|
||||
"testonly": "mocha --require @babel/register --recursive --timeout 3s ./ui/**/*_test.js && CGO_ENABLED=1 go test ./... -race -timeout 30s",
|
||||
"test": "npm run generate && npm run testonly"
|
||||
},
|
||||
"author": "",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
55
sshwifty.conf.example.json
Normal file
55
sshwifty.conf.example.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"HostName": "",
|
||||
"SharedKey": "WEB_ACCESS_PASSWORD",
|
||||
"DialTimeout": 5,
|
||||
"Socks5": "",
|
||||
"Socks5User": "",
|
||||
"Socks5Password": "",
|
||||
"Servers": [
|
||||
{
|
||||
"ListenInterface": "127.0.0.1",
|
||||
"ListenPort": 8182,
|
||||
"InitialTimeout": 3,
|
||||
"ReadTimeout": 60,
|
||||
"WriteTimeout": 60,
|
||||
"HeartbeatTimeout": 20,
|
||||
"ReadDelay": 10,
|
||||
"WriteDelay": 10,
|
||||
"TLSCertificateFile": "",
|
||||
"TLSCertificateKeyFile": "",
|
||||
"ServerMessage": "Programmers in China launched an online campaign against [implicitly forced overtime work](https://en.wikipedia.org/wiki/996_working_hour_system) in pursuit of balanced work-life relationship. Sshwifty wouldn't exist if its author must work such extreme hours. If you're benefiting from hobbyist projects like this one, please consider to support the action."
|
||||
}
|
||||
],
|
||||
"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 Will be sent to client-END RSA PRI...\n",
|
||||
"Authentication": "Private Key",
|
||||
"Fingerprint": "SHA256:bgO...."
|
||||
}
|
||||
},
|
||||
{
|
||||
"Title": "My own super expensive router",
|
||||
"Type": "Telnet",
|
||||
"Host": "10.0.0.1",
|
||||
"Meta": {
|
||||
"Encoding": "ibm866"
|
||||
}
|
||||
}
|
||||
],
|
||||
"OnlyAllowPresetRemotes": false
|
||||
}
|
||||
54
sshwifty.go
Normal file
54
sshwifty.go
Normal file
@ -0,0 +1,54 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/nirui/sshwifty/application"
|
||||
"github.com/nirui/sshwifty/application/commands"
|
||||
"github.com/nirui/sshwifty/application/configuration"
|
||||
"github.com/nirui/sshwifty/application/controller"
|
||||
"github.com/nirui/sshwifty/application/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configLoaders := make([]configuration.Loader, 0, 2)
|
||||
|
||||
if len(os.Getenv("SSHWIFTY_CONFIG")) > 0 {
|
||||
configLoaders = append(configLoaders,
|
||||
configuration.File(os.Getenv("SSHWIFTY_CONFIG")))
|
||||
} else {
|
||||
configLoaders = append(configLoaders, configuration.File(""))
|
||||
configLoaders = append(configLoaders, configuration.Enviro())
|
||||
}
|
||||
|
||||
e := application.
|
||||
New(os.Stderr, log.NewDebugOrNonDebugWriter(
|
||||
len(os.Getenv("SSHWIFTY_DEBUG")) > 0, application.Name, os.Stderr)).
|
||||
Run(configuration.Redundant(configLoaders...),
|
||||
application.DefaultProccessSignallerBuilder,
|
||||
commands.New(),
|
||||
controller.Builder)
|
||||
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
8
traefik-forward-auth.env
Normal file
8
traefik-forward-auth.env
Normal file
@ -0,0 +1,8 @@
|
||||
## KEYCLOAK ENV
|
||||
|
||||
CLIENT_ID=sshwifty
|
||||
CLIENT_SECRET=<your keycloak client secret>
|
||||
OIDC_ISSUER=https://<your keycloak URL>/auth/realms/garagenum
|
||||
SECRET=<a random string to secure your cookie>
|
||||
AUTH_HOST=https://id.legaragenumerique.fr
|
||||
COOKIE_DOMAIN=<the root FQDN of your domain>
|
||||
73
ui/app.css
Normal file
73
ui/app.css
Normal file
@ -0,0 +1,73 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
#app {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
body.app-error {
|
||||
background: #b44;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#app-loading {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#app-loading-frame {
|
||||
flex: 0 0;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#app-loading-icon {
|
||||
background: url("./widgets/busy.svg") center center no-repeat;
|
||||
background-size: contain;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
#app-loading-error {
|
||||
font-size: 5em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#app-loading-title {
|
||||
color: #fab;
|
||||
font-size: 1.2em;
|
||||
font-weight: lighter;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#app-loading-title.error {
|
||||
color: #fff;
|
||||
}
|
||||
459
ui/app.js
Normal file
459
ui/app.js
Normal file
@ -0,0 +1,459 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Vue from "vue";
|
||||
import "./app.css";
|
||||
import Auth from "./auth.vue";
|
||||
import { Color as ControlColor } from "./commands/color.js";
|
||||
import { Commands } from "./commands/commands.js";
|
||||
import { Controls } from "./commands/controls.js";
|
||||
import { Presets } from "./commands/presets.js";
|
||||
import * as ssh from "./commands/ssh.js";
|
||||
import * as telnet from "./commands/telnet.js";
|
||||
import "./common.css";
|
||||
import * as sshctl from "./control/ssh.js";
|
||||
import * as telnetctl from "./control/telnet.js";
|
||||
import * as cipher from "./crypto.js";
|
||||
import Home from "./home.vue";
|
||||
import "./landing.css";
|
||||
import Loading from "./loading.vue";
|
||||
import { Socket } from "./socket.js";
|
||||
import * as stream from "./stream/common";
|
||||
import * as xhr from "./xhr.js";
|
||||
|
||||
const backendQueryRetryDelay = 2000;
|
||||
|
||||
const maxTimeDiff = 30000;
|
||||
|
||||
const updateIndicatorMaxDisplayTime = 3000;
|
||||
|
||||
const mainTemplate = `
|
||||
<home
|
||||
v-if="page == 'app'"
|
||||
:host-path="hostPath"
|
||||
:query="query"
|
||||
:connection="socket"
|
||||
:controls="controls"
|
||||
:commands="commands"
|
||||
:server-message="serverMessage"
|
||||
:preset-data="presetData.presets"
|
||||
:restricted-to-presets="presetData.restricted"
|
||||
:view-port="viewPort"
|
||||
@navigate-to="changeURLHash"
|
||||
@tab-opened="tabOpened"
|
||||
@tab-closed="tabClosed"
|
||||
@tab-updated="tabUpdated"
|
||||
></home>
|
||||
<auth
|
||||
v-else-if="page == 'auth'"
|
||||
:error="authErr"
|
||||
@auth="submitAuth"
|
||||
></auth>
|
||||
<loading v-else :error="loadErr"></loading>
|
||||
`.trim();
|
||||
|
||||
const socksInterface = "/sshwifty/socket";
|
||||
const socksVerificationInterface = socksInterface + "/verify";
|
||||
const socksKeyTimeTruncater = 100 * 1000;
|
||||
|
||||
function startApp(rootEl) {
|
||||
const pageTitle = document.title;
|
||||
|
||||
let uiControlColor = new ControlColor();
|
||||
|
||||
function getCurrentKeyMixer() {
|
||||
return Number(
|
||||
Math.trunc(new Date().getTime() / socksKeyTimeTruncater),
|
||||
).toString();
|
||||
}
|
||||
|
||||
async function buildSocketKey(privateKey) {
|
||||
return new Uint8Array(
|
||||
await cipher.hmac512(
|
||||
stream.buildBufferFromString(privateKey),
|
||||
stream.buildBufferFromString(getCurrentKeyMixer()),
|
||||
),
|
||||
).slice(0, 16);
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: rootEl,
|
||||
components: {
|
||||
loading: Loading,
|
||||
auth: Auth,
|
||||
home: Home,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hostPath:
|
||||
window.location.protocol +
|
||||
"//" +
|
||||
window.location.host +
|
||||
window.location.pathname,
|
||||
query:
|
||||
window.location.hash.length > 0 &&
|
||||
window.location.hash.indexOf("#") === 0
|
||||
? window.location.hash.slice(1, window.location.hash.length)
|
||||
: "",
|
||||
page: "loading",
|
||||
key: "",
|
||||
serverMessage: "",
|
||||
presetData: {
|
||||
presets: new Presets([]),
|
||||
restricted: false,
|
||||
},
|
||||
authErr: "",
|
||||
loadErr: "",
|
||||
socket: null,
|
||||
controls: new Controls([
|
||||
new telnetctl.Telnet(uiControlColor),
|
||||
new sshctl.SSH(uiControlColor),
|
||||
]),
|
||||
commands: new Commands([new telnet.Command(), new ssh.Command()]),
|
||||
tabUpdateIndicator: null,
|
||||
viewPort: {
|
||||
dim: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
renew(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
},
|
||||
},
|
||||
},
|
||||
viewPortUpdaters: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
dimResizer: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
loadErr() {
|
||||
this.isErrored()
|
||||
? document.body.classList.add("app-error")
|
||||
: document.body.classList.remove("app-error");
|
||||
},
|
||||
authErr() {
|
||||
this.isErrored()
|
||||
? document.body.classList.add("app-error")
|
||||
: document.body.classList.remove("app-error");
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const self = this;
|
||||
|
||||
self.tryInitialAuth();
|
||||
|
||||
self.viewPortUpdaters.dimResizer = () => {
|
||||
self.viewPortUpdaters.height = window.innerHeight;
|
||||
self.viewPortUpdaters.width = window.innerWidth;
|
||||
|
||||
self.$nextTick(() => {
|
||||
self.viewPort.dim.renew(
|
||||
self.viewPortUpdaters.width,
|
||||
self.viewPortUpdaters.height,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", self.viewPortUpdaters.dimResizer);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener("resize", self.viewPortUpdaters.dimResizer);
|
||||
},
|
||||
methods: {
|
||||
changeTitleInfo(newTitleInfo) {
|
||||
document.title = newTitleInfo + " " + pageTitle;
|
||||
},
|
||||
resetTitleInfo() {
|
||||
document.title = pageTitle;
|
||||
},
|
||||
changeURLHash(newHash) {
|
||||
window.location.hash = newHash;
|
||||
},
|
||||
isErrored() {
|
||||
return this.authErr.length > 0 || this.loadErr.length > 0;
|
||||
},
|
||||
async getSocketAuthKey(privateKey) {
|
||||
const enc = new TextEncoder(),
|
||||
rTime = Number(Math.trunc(new Date().getTime() / 100000));
|
||||
|
||||
var finalKey = "";
|
||||
|
||||
if (privateKey.length <= 0) {
|
||||
finalKey = "DEFAULT VERIFY KEY";
|
||||
} else {
|
||||
finalKey = privateKey;
|
||||
}
|
||||
|
||||
return new Uint8Array(
|
||||
await cipher.hmac512(enc.encode(finalKey), enc.encode(rTime)),
|
||||
).slice(0, 32);
|
||||
},
|
||||
buildBackendSocketURLs() {
|
||||
let r = {
|
||||
webSocket: "",
|
||||
keepAlive: "",
|
||||
};
|
||||
|
||||
switch (location.protocol) {
|
||||
case "https:":
|
||||
r.webSocket = "wss://";
|
||||
break;
|
||||
|
||||
default:
|
||||
r.webSocket = "ws://";
|
||||
}
|
||||
|
||||
r.webSocket += location.host + socksInterface;
|
||||
r.keepAlive = location.protocol + "//" + location.host + socksInterface;
|
||||
|
||||
return r;
|
||||
},
|
||||
buildSocket(key, dialTimeout, heartbeatInterval) {
|
||||
return new Socket(
|
||||
this.buildBackendSocketURLs(),
|
||||
key,
|
||||
dialTimeout * 1000,
|
||||
heartbeatInterval * 1000,
|
||||
);
|
||||
},
|
||||
executeHomeApp(authResult, key) {
|
||||
let authData = JSON.parse(authResult.data);
|
||||
this.serverMessage = authData.server_message
|
||||
? authData.server_message
|
||||
: "";
|
||||
this.presetData = {
|
||||
presets: new Presets(authData.presets ? authData.presets : []),
|
||||
restricted: authResult.onlyAllowPresetRemotes,
|
||||
};
|
||||
this.socket = this.buildSocket(
|
||||
key,
|
||||
authResult.timeout,
|
||||
authResult.heartbeat,
|
||||
);
|
||||
this.page = "app";
|
||||
},
|
||||
async doAuth(privateKey) {
|
||||
let result = await this.requestAuth(privateKey);
|
||||
|
||||
if (result.key) {
|
||||
this.key = result.key;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
async requestAuth(privateKey) {
|
||||
let authKey =
|
||||
!privateKey || !this.key
|
||||
? null
|
||||
: await this.getSocketAuthKey(privateKey);
|
||||
|
||||
let h = await xhr.get(socksVerificationInterface, {
|
||||
"X-Key": authKey
|
||||
? btoa(String.fromCharCode.apply(null, authKey))
|
||||
: "",
|
||||
});
|
||||
|
||||
let serverDate = h.getResponseHeader("Date");
|
||||
|
||||
return {
|
||||
result: h.status,
|
||||
key: h.getResponseHeader("X-Key"),
|
||||
timeout: h.getResponseHeader("X-Timeout"),
|
||||
heartbeat: h.getResponseHeader("X-Heartbeat"),
|
||||
date: serverDate ? new Date(serverDate) : null,
|
||||
data: h.responseText,
|
||||
onlyAllowPresetRemotes:
|
||||
h.getResponseHeader("X-OnlyAllowPresetRemotes") === "yes",
|
||||
};
|
||||
},
|
||||
async tryInitialAuth() {
|
||||
try {
|
||||
let result = await this.doAuth("");
|
||||
|
||||
if (result.date) {
|
||||
let serverTime = result.date.getTime(),
|
||||
clientTime = new Date().getTime(),
|
||||
timeDiff = Math.abs(serverTime - clientTime);
|
||||
|
||||
if (timeDiff > maxTimeDiff) {
|
||||
this.loadErr =
|
||||
"The time difference between this client " +
|
||||
"and the backend server is beyond operational limit.\r\n\r\n" +
|
||||
"Please try reload the page, and if the problem persisted, " +
|
||||
"consider to adjust your local time so both the client and " +
|
||||
"the server are running at same date time";
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let self = this;
|
||||
switch (result.result) {
|
||||
case 200:
|
||||
this.executeHomeApp(result, {
|
||||
async fetch() {
|
||||
let result = await self.doAuth("");
|
||||
|
||||
if (result.result !== 200) {
|
||||
throw new Error(
|
||||
"Unable to fetch key from remote, unexpected " +
|
||||
"error code: " +
|
||||
result.result,
|
||||
);
|
||||
}
|
||||
|
||||
return await buildSocketKey(atob(result.key) + "+");
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 403:
|
||||
this.page = "auth";
|
||||
break;
|
||||
|
||||
case 0:
|
||||
setTimeout(() => {
|
||||
this.tryInitialAuth();
|
||||
}, backendQueryRetryDelay);
|
||||
break;
|
||||
|
||||
default:
|
||||
alert("Unexpected backend query status: " + result.result);
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadErr = "Unable to initialize client application: " + e;
|
||||
}
|
||||
},
|
||||
async submitAuth(passphrase) {
|
||||
this.authErr = "";
|
||||
|
||||
try {
|
||||
let result = await this.doAuth(passphrase);
|
||||
|
||||
let self = this;
|
||||
switch (result.result) {
|
||||
case 200:
|
||||
this.executeHomeApp(result, {
|
||||
async fetch() {
|
||||
let result = await self.doAuth(passphrase);
|
||||
|
||||
if (result.result !== 200) {
|
||||
throw new Error(
|
||||
"Unable to fetch key from remote, unexpected " +
|
||||
"error code: " +
|
||||
result.result,
|
||||
);
|
||||
}
|
||||
|
||||
return await buildSocketKey(
|
||||
atob(result.key) + "+" + passphrase,
|
||||
);
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 403:
|
||||
this.authErr = "Authentication échoué. Mauvais mot de passe?";
|
||||
break;
|
||||
|
||||
default:
|
||||
this.authErr =
|
||||
"Unexpected backend query status: " + result.result;
|
||||
}
|
||||
} catch (e) {
|
||||
this.authErr = "Authentification impossible: " + e;
|
||||
}
|
||||
},
|
||||
updateTabTitleInfo(tabs, updated) {
|
||||
if (tabs.length <= 0) {
|
||||
this.resetTitleInfo();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.changeTitleInfo("(" + tabs.length + (updated ? "*" : "") + ")");
|
||||
},
|
||||
tabOpened(tabs) {
|
||||
this.tabUpdated(tabs);
|
||||
},
|
||||
tabClosed(tabs) {
|
||||
if (tabs.length > 0) {
|
||||
this.updateTabTitleInfo(tabs, this.tabUpdateIndicator !== null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tabUpdateIndicator) {
|
||||
clearTimeout(this.tabUpdateIndicator);
|
||||
this.tabUpdateIndicator = null;
|
||||
}
|
||||
|
||||
this.updateTabTitleInfo(tabs, false);
|
||||
},
|
||||
tabUpdated(tabs) {
|
||||
if (this.tabUpdateIndicator) {
|
||||
clearTimeout(this.tabUpdateIndicator);
|
||||
this.tabUpdateIndicator = null;
|
||||
}
|
||||
|
||||
this.updateTabTitleInfo(tabs, true);
|
||||
|
||||
this.tabUpdateIndicator = setTimeout(() => {
|
||||
this.tabUpdateIndicator = null;
|
||||
this.updateTabTitleInfo(tabs, false);
|
||||
}, updateIndicatorMaxDisplayTime);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initializeClient() {
|
||||
let landingRoot = document.getElementById("landing");
|
||||
|
||||
if (!landingRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("Currently in Development environment");
|
||||
}
|
||||
|
||||
window.addEventListener("unhandledrejection", function (e) {
|
||||
console.error("Error:", e);
|
||||
});
|
||||
|
||||
window.addEventListener("error", function (e) {
|
||||
console.error("Error:", e);
|
||||
});
|
||||
|
||||
landingRoot.parentNode.removeChild(landingRoot);
|
||||
|
||||
let normalRoot = document.createElement("div");
|
||||
normalRoot.setAttribute("id", "app");
|
||||
normalRoot.innerHTML = mainTemplate;
|
||||
|
||||
document.body.insertBefore(normalRoot, document.body.firstChild);
|
||||
|
||||
startApp(normalRoot);
|
||||
}
|
||||
|
||||
window.addEventListener("load", initializeClient);
|
||||
document.addEventListener("load", initializeClient);
|
||||
document.addEventListener("DOMContentLoaded", initializeClient);
|
||||
126
ui/auth.vue
Normal file
126
ui/auth.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="auth">
|
||||
<div id="auth-frame">
|
||||
<div id="auth-content">
|
||||
<h1>Authentication required</h1>
|
||||
|
||||
<form class="form1" action="javascript:;" method="POST" @submit="auth">
|
||||
<fieldset>
|
||||
<div
|
||||
class="field"
|
||||
:class="{
|
||||
error: passphraseErr.length > 0 || error.length > 0,
|
||||
}"
|
||||
>
|
||||
Passphrase
|
||||
|
||||
<input
|
||||
v-model="passphrase"
|
||||
v-focus="true"
|
||||
:disabled="submitting"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
name="field.field.name"
|
||||
placeholder="----------"
|
||||
autofocus="autofocus"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="passphraseErr.length <= 0 && error.length <= 0"
|
||||
class="message"
|
||||
>
|
||||
A valid password is required in order to use this
|
||||
<a href="https://github.com/nirui/sshwifty">Sshwifty</a>
|
||||
instance
|
||||
</div>
|
||||
<div v-else class="error">
|
||||
{{ passphraseErr || error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button type="submit" :disabled="submitting" @click="auth">
|
||||
Authenticate
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
directives: {
|
||||
focus: {
|
||||
inserted(el, binding) {
|
||||
if (!binding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.focus();
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
error: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
submitting: false,
|
||||
passphrase: "",
|
||||
passphraseErr: "",
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
error(newVal) {
|
||||
if (newVal.length > 0) {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {},
|
||||
methods: {
|
||||
auth() {
|
||||
if (this.passphrase.length <= 0) {
|
||||
this.passphraseErr = "Passphrase cannot be empty";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
|
||||
this.passphraseErr = "";
|
||||
|
||||
this.$emit("auth", this.passphrase);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
230
ui/commands/address.js
Normal file
230
ui/commands/address.js
Normal file
@ -0,0 +1,230 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as common from "./common.js";
|
||||
import Exception from "./exception.js";
|
||||
|
||||
export const LOOPBACK = 0x00;
|
||||
export const IPV4 = 0x01;
|
||||
export const IPV6 = 0x02;
|
||||
export const HOSTNAME = 0x03;
|
||||
|
||||
export const MAX_ADDR_LEN = 0x3f;
|
||||
|
||||
export class Address {
|
||||
/**
|
||||
* Read builds an Address from data readed from the reader
|
||||
*
|
||||
* @param {reader.Reader} rd The reader
|
||||
*
|
||||
* @returns {Address} The Address
|
||||
*
|
||||
* @throws {Exception} when address type is invalid
|
||||
*/
|
||||
static async read(rd) {
|
||||
let readed = await reader.readN(rd, 3),
|
||||
portNum = 0,
|
||||
addrType = LOOPBACK,
|
||||
addrData = null;
|
||||
|
||||
portNum |= readed[0];
|
||||
portNum <<= 8;
|
||||
portNum |= readed[1];
|
||||
|
||||
addrType = readed[2] >> 6;
|
||||
|
||||
switch (addrType) {
|
||||
case LOOPBACK:
|
||||
break;
|
||||
|
||||
case IPV4:
|
||||
addrData = await reader.readN(rd, 4);
|
||||
break;
|
||||
|
||||
case IPV6:
|
||||
addrData = await reader.readN(rd, 16);
|
||||
break;
|
||||
|
||||
case HOSTNAME:
|
||||
addrData = await reader.readN(rd, 0x3f & readed[2]);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown address type");
|
||||
}
|
||||
|
||||
return new Address(addrType, addrData, portNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} type Type of the address
|
||||
* @param {Uint8Array} address Address data
|
||||
* @param {number} port port number of the address
|
||||
*
|
||||
*/
|
||||
constructor(type, address, port) {
|
||||
this.addrType = type;
|
||||
this.addrData = address;
|
||||
this.addrPort = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the address type
|
||||
*
|
||||
*/
|
||||
type() {
|
||||
return this.addrType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the address data
|
||||
*
|
||||
*/
|
||||
address() {
|
||||
return this.addrData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the port data
|
||||
*
|
||||
*/
|
||||
port() {
|
||||
return this.addrPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer returns the marshalled address
|
||||
*
|
||||
* @returns {Uint8Array} Marshalled address
|
||||
*
|
||||
* @throws {Exception} When address data is invalid
|
||||
*
|
||||
*/
|
||||
buffer() {
|
||||
switch (this.type()) {
|
||||
case LOOPBACK:
|
||||
return new Uint8Array([
|
||||
this.addrPort >> 8,
|
||||
this.addrPort & 0xff,
|
||||
LOOPBACK << 6,
|
||||
]);
|
||||
|
||||
case IPV4:
|
||||
if (this.addrData.length != 4) {
|
||||
throw new Exception("Invalid address length");
|
||||
}
|
||||
|
||||
return new Uint8Array([
|
||||
this.addrPort >> 8,
|
||||
this.addrPort & 0xff,
|
||||
IPV4 << 6,
|
||||
this.addrData[0],
|
||||
this.addrData[1],
|
||||
this.addrData[2],
|
||||
this.addrData[3],
|
||||
]);
|
||||
|
||||
case IPV6:
|
||||
if (this.addrData.length != 16) {
|
||||
throw new Exception("Invalid address length");
|
||||
}
|
||||
|
||||
return new Uint8Array([
|
||||
this.addrPort >> 8,
|
||||
this.addrPort & 0xff,
|
||||
IPV6 << 6,
|
||||
this.addrData[0],
|
||||
this.addrData[1],
|
||||
this.addrData[2],
|
||||
this.addrData[3],
|
||||
this.addrData[4],
|
||||
this.addrData[5],
|
||||
this.addrData[6],
|
||||
this.addrData[7],
|
||||
this.addrData[8],
|
||||
this.addrData[9],
|
||||
this.addrData[10],
|
||||
this.addrData[11],
|
||||
this.addrData[12],
|
||||
this.addrData[13],
|
||||
this.addrData[14],
|
||||
this.addrData[15],
|
||||
]);
|
||||
|
||||
case HOSTNAME:
|
||||
if (this.addrData.length > MAX_ADDR_LEN) {
|
||||
throw new Exception("Host name cannot longer than " + MAX_ADDR_LEN);
|
||||
}
|
||||
|
||||
{
|
||||
let dataBuf = new Uint8Array(this.addrData.length + 3);
|
||||
|
||||
dataBuf[0] = (this.addrPort >> 8) & 0xff;
|
||||
dataBuf[1] = this.addrPort & 0xff;
|
||||
dataBuf[2] = HOSTNAME << 6;
|
||||
dataBuf[2] |= this.addrData.length;
|
||||
|
||||
dataBuf.set(this.addrData, 3);
|
||||
|
||||
return dataBuf;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown address type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get address data
|
||||
*
|
||||
* @param {string} s Address string
|
||||
* @param {number} defaultPort Default port number
|
||||
*
|
||||
* @returns {object} result
|
||||
*
|
||||
* @throws {Exception} when the address is invalid
|
||||
*/
|
||||
export function parseHostPort(s, defaultPort) {
|
||||
let d = common.splitHostPort(s, defaultPort),
|
||||
t = HOSTNAME;
|
||||
|
||||
switch (d.type) {
|
||||
case "IPv4":
|
||||
t = IPV4;
|
||||
break;
|
||||
|
||||
case "IPv6":
|
||||
t = IPV6;
|
||||
break;
|
||||
|
||||
case "Hostname":
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception("Invalid address type");
|
||||
}
|
||||
|
||||
return {
|
||||
type: t,
|
||||
address: d.addr,
|
||||
port: d.port,
|
||||
};
|
||||
}
|
||||
102
ui/commands/address_test.js
Normal file
102
ui/commands/address_test.js
Normal file
@ -0,0 +1,102 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as address from "./address.js";
|
||||
|
||||
describe("Address", () => {
|
||||
it("Address Loopback", async () => {
|
||||
let addr = new address.Address(address.LOOPBACK, null, 8080),
|
||||
buf = addr.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(), (data) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(buf);
|
||||
|
||||
let addr2 = await address.Address.read(r);
|
||||
|
||||
assert.strictEqual(addr2.type(), addr.type());
|
||||
assert.deepStrictEqual(addr2.address(), addr.address());
|
||||
assert.strictEqual(addr2.port(), addr.port());
|
||||
});
|
||||
|
||||
it("Address IPv4", async () => {
|
||||
let addr = new address.Address(
|
||||
address.IPV4,
|
||||
new Uint8Array([127, 0, 0, 1]),
|
||||
8080,
|
||||
),
|
||||
buf = addr.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(buf);
|
||||
|
||||
let addr2 = await address.Address.read(r);
|
||||
|
||||
assert.strictEqual(addr2.type(), addr.type());
|
||||
assert.deepStrictEqual(addr2.address(), addr.address());
|
||||
assert.strictEqual(addr2.port(), addr.port());
|
||||
});
|
||||
|
||||
it("Address IPv6", async () => {
|
||||
let addr = new address.Address(
|
||||
address.IPV6,
|
||||
new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]),
|
||||
8080,
|
||||
),
|
||||
buf = addr.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(buf);
|
||||
|
||||
let addr2 = await address.Address.read(r);
|
||||
|
||||
assert.strictEqual(addr2.type(), addr.type());
|
||||
assert.deepStrictEqual(addr2.address(), addr.address());
|
||||
assert.strictEqual(addr2.port(), addr.port());
|
||||
});
|
||||
|
||||
it("Address HostName", async () => {
|
||||
let addr = new address.Address(
|
||||
address.HOSTNAME,
|
||||
new Uint8Array(["v", "a", "g", "u", "l", "1", "2", "3"]),
|
||||
8080,
|
||||
),
|
||||
buf = addr.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(buf);
|
||||
|
||||
let addr2 = await address.Address.read(r);
|
||||
|
||||
assert.strictEqual(addr2.type(), addr.type());
|
||||
assert.deepStrictEqual(addr2.address(), addr.address());
|
||||
assert.strictEqual(addr2.port(), addr.port());
|
||||
});
|
||||
});
|
||||
107
ui/commands/color.js
Normal file
107
ui/commands/color.js
Normal file
@ -0,0 +1,107 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Get one color hex byte
|
||||
*
|
||||
* @param {number} from Min color number
|
||||
* @param {number} to Max color number
|
||||
*
|
||||
* @returns {string} color byte in string
|
||||
*
|
||||
*/
|
||||
function getRandHex(from, to) {
|
||||
let color = Math.random() * (to - from) + from,
|
||||
colorDark = color - color / 20;
|
||||
|
||||
let r = Math.round(color).toString(16),
|
||||
rDark = Math.round(colorDark).toString(16);
|
||||
|
||||
if (r.length % 2 !== 0) {
|
||||
r = "0" + r;
|
||||
}
|
||||
|
||||
if (rDark.length % 2 !== 0) {
|
||||
rDark = "0" + rDark;
|
||||
}
|
||||
|
||||
return [r, rDark];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rand color
|
||||
*
|
||||
* @param {number} from Min color number
|
||||
* @param {number} to Max color number
|
||||
*
|
||||
* @returns {string} Color bytes in string
|
||||
*/
|
||||
function getRandColor(from, to) {
|
||||
let r = getRandHex(from, to),
|
||||
g = getRandHex(from, to),
|
||||
b = getRandHex(from, to);
|
||||
|
||||
return ["#" + r[0] + g[0] + b[0], "#" + r[1] + g[1] + b[1]];
|
||||
}
|
||||
|
||||
export class Color {
|
||||
/**
|
||||
* constructor
|
||||
*/
|
||||
constructor() {
|
||||
this.assignedColors = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one color
|
||||
*
|
||||
* @returns {string} Color code
|
||||
*
|
||||
*/
|
||||
get() {
|
||||
const maxTries = 10;
|
||||
let tried = 0;
|
||||
|
||||
for (;;) {
|
||||
let color = getRandColor(0x22, 0x33);
|
||||
|
||||
if (this.assignedColors[color[0]]) {
|
||||
tried++;
|
||||
|
||||
if (tried < maxTries) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.assignedColors[color[0]] = true;
|
||||
|
||||
return {
|
||||
color: color[0],
|
||||
dark: color[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* forget already assigned color
|
||||
*
|
||||
* @param {string} color Color code
|
||||
*/
|
||||
forget(color) {
|
||||
delete this.assignedColors[color];
|
||||
}
|
||||
}
|
||||
880
ui/commands/commands.js
Normal file
880
ui/commands/commands.js
Normal file
@ -0,0 +1,880 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as subscribe from "../stream/subscribe.js";
|
||||
import Exception from "./exception.js";
|
||||
import * as presets from "./presets.js";
|
||||
|
||||
export const NEXT_PROMPT = 1;
|
||||
export const NEXT_WAIT = 2;
|
||||
export const NEXT_DONE = 3;
|
||||
|
||||
export class Result {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} name Result type
|
||||
* @param {Info} info Result info
|
||||
* @param {object} control Result controller
|
||||
* @param {string} ui User interfact this command will use
|
||||
*/
|
||||
constructor(name, info, control, ui) {
|
||||
this.name = name;
|
||||
this.info = info;
|
||||
this.control = control;
|
||||
this.ui = ui;
|
||||
}
|
||||
}
|
||||
|
||||
class Done {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data Step data
|
||||
*
|
||||
*/
|
||||
constructor(data) {
|
||||
this.s = !!data.success;
|
||||
this.d = data.successData;
|
||||
this.errorTitle = data.errorTitle;
|
||||
this.errorMessage = data.errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the error of current Done
|
||||
*
|
||||
* @returns {string} title
|
||||
*
|
||||
*/
|
||||
error() {
|
||||
return this.errorTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the error message of current Done
|
||||
*
|
||||
* @returns {string} message
|
||||
*
|
||||
*/
|
||||
message() {
|
||||
return this.errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not current Done is representing a success
|
||||
*
|
||||
* @returns {boolean} True when success, false otherwise
|
||||
*/
|
||||
success() {
|
||||
return this.s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns final data
|
||||
*
|
||||
* @returns {Result} Successful result
|
||||
*/
|
||||
data() {
|
||||
return this.d;
|
||||
}
|
||||
}
|
||||
|
||||
class Wait {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data Step data
|
||||
*
|
||||
*/
|
||||
constructor(data) {
|
||||
this.t = data.title;
|
||||
this.m = data.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the title of current Wait
|
||||
*
|
||||
* @returns {string} title
|
||||
*
|
||||
*/
|
||||
title() {
|
||||
return this.t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the message of current Wait
|
||||
*
|
||||
* @returns {string} message
|
||||
*
|
||||
*/
|
||||
message() {
|
||||
return this.m;
|
||||
}
|
||||
}
|
||||
|
||||
const defField = {
|
||||
name: "",
|
||||
description: "",
|
||||
type: "",
|
||||
value: "",
|
||||
example: "",
|
||||
readonly: false,
|
||||
suggestions(input) {
|
||||
return [];
|
||||
},
|
||||
verify(v) {
|
||||
return "";
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Prompt field
|
||||
*
|
||||
* @param {object} def Field default value
|
||||
* @param {object} f Field value
|
||||
*
|
||||
* @returns {object} Field data
|
||||
*
|
||||
* @throws {Exception} When input field is invalid
|
||||
*
|
||||
*/
|
||||
export function field(def, f) {
|
||||
let n = {};
|
||||
|
||||
for (let i in def) {
|
||||
n[i] = def[i];
|
||||
}
|
||||
|
||||
for (let i in f) {
|
||||
if (typeof n[i] === typeof f[i]) {
|
||||
n[i] = f[i];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
'Field data type for "' +
|
||||
i +
|
||||
'" was unmatched. Expecting "' +
|
||||
typeof n[i] +
|
||||
'", got "' +
|
||||
typeof f[i] +
|
||||
'" instead',
|
||||
);
|
||||
}
|
||||
|
||||
if (!n["name"]) {
|
||||
throw new Exception('Field "name" must be specified');
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a group of field value
|
||||
*
|
||||
* @param {object} definitions Definition of a group of fields
|
||||
* @param {Array<object>} fs Data of the field group
|
||||
*
|
||||
* @returns {Array<object>} Result fields
|
||||
*
|
||||
* @throws {Exception} When input field is invalid
|
||||
*
|
||||
*/
|
||||
export function fields(definitions, fs) {
|
||||
let fss = [];
|
||||
|
||||
for (let i in fs) {
|
||||
if (!fs[i]["name"]) {
|
||||
throw new Exception('Field "name" must be specified');
|
||||
}
|
||||
|
||||
if (!definitions[fs[i].name]) {
|
||||
throw new Exception('Undefined field "' + fs[i].name + '"');
|
||||
}
|
||||
|
||||
fss.push(field(definitions[fs[i].name], fs[i]));
|
||||
}
|
||||
|
||||
return fss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command fields with preset data
|
||||
*
|
||||
* @param {object} definitions Definition of a group of fields
|
||||
* @param {object} fieldsData field data object, formated like a `defField`
|
||||
* @param {presets.Preset} presetData Preset data
|
||||
* @param {function} presetApplied Called when a preset is used for a field
|
||||
*
|
||||
* @returns {object}
|
||||
*
|
||||
*/
|
||||
export function fieldsWithPreset(
|
||||
definitions,
|
||||
fieldsData,
|
||||
presetData,
|
||||
presetApplied,
|
||||
) {
|
||||
let newFields = fields(definitions, fieldsData);
|
||||
|
||||
for (let i in newFields) {
|
||||
try {
|
||||
newFields[i].value = presetData.meta(newFields[i].name);
|
||||
newFields[i].readonly = true;
|
||||
|
||||
presetApplied(newFields[i].name);
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
return newFields;
|
||||
}
|
||||
|
||||
class Prompt {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data Step data
|
||||
*
|
||||
* @throws {Exception} If the field verify is not a function while
|
||||
* not null
|
||||
*/
|
||||
constructor(data) {
|
||||
this.t = data.title;
|
||||
this.m = data.message;
|
||||
this.a = data.actionText;
|
||||
this.r = data.respond;
|
||||
this.c = data.cancel;
|
||||
|
||||
this.i = [];
|
||||
this.f = {};
|
||||
|
||||
for (let i in data.inputs) {
|
||||
let f = field(defField, data.inputs[i]);
|
||||
|
||||
this.i.push(f);
|
||||
|
||||
this.f[data.inputs[i].name.toLowerCase()] = {
|
||||
value: f.value,
|
||||
verify: f.verify,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the title of current Prompt
|
||||
*
|
||||
* @returns {string} title
|
||||
*
|
||||
*/
|
||||
title() {
|
||||
return this.t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the message of current Prompt
|
||||
*
|
||||
* @returns {string} message
|
||||
*
|
||||
*/
|
||||
message() {
|
||||
return this.m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the input field of current prompt
|
||||
*
|
||||
* @returns {array} Input fields
|
||||
*
|
||||
*/
|
||||
inputs() {
|
||||
let inputs = [];
|
||||
|
||||
for (let i in this.i) {
|
||||
inputs.push(this.i[i]);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the action
|
||||
*
|
||||
* @returns {string} Action name
|
||||
*
|
||||
*/
|
||||
actionText() {
|
||||
return this.a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive the submit of current prompt
|
||||
*
|
||||
* @param {object} inputs Input value
|
||||
*
|
||||
* @returns {any} The result of the step responder
|
||||
*
|
||||
* @throws {Exception} When the field is undefined or invalid
|
||||
*
|
||||
*/
|
||||
submit(inputs) {
|
||||
let fields = {};
|
||||
|
||||
for (let i in this.f) {
|
||||
fields[i] = this.f[i].value;
|
||||
}
|
||||
|
||||
for (let i in inputs) {
|
||||
let k = i.toLowerCase();
|
||||
|
||||
if (typeof fields[k] === "undefined") {
|
||||
throw new Exception('Field "' + k + '" is undefined');
|
||||
}
|
||||
|
||||
try {
|
||||
this.f[k].verify(inputs[i]);
|
||||
} catch (e) {
|
||||
throw new Exception('Field "' + k + '" is invalid: ' + e);
|
||||
}
|
||||
|
||||
fields[k] = inputs[i];
|
||||
}
|
||||
|
||||
return this.r(fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel current wait operation
|
||||
*
|
||||
*/
|
||||
cancel() {
|
||||
return this.c();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Wizard step
|
||||
*
|
||||
* @param {string} type Step type
|
||||
* @param {object} data Step data
|
||||
*
|
||||
* @returns {object} Step data
|
||||
*
|
||||
*/
|
||||
function next(type, data) {
|
||||
return {
|
||||
type() {
|
||||
return type;
|
||||
},
|
||||
data() {
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data for a Done step of the wizard
|
||||
*
|
||||
* @param {boolean} success
|
||||
* @param {Success} successData
|
||||
* @param {string} errorTitle
|
||||
* @param {string} errorMessage
|
||||
*
|
||||
* @returns {object} Done step data
|
||||
*
|
||||
*/
|
||||
export function done(success, successData, errorTitle, errorMessage) {
|
||||
return next(NEXT_DONE, {
|
||||
success: success,
|
||||
successData: successData,
|
||||
errorTitle: errorTitle,
|
||||
errorMessage: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data for a Wait step of the wizard
|
||||
*
|
||||
* @param {string} title Waiter title
|
||||
* @param {message} message Waiter message
|
||||
*
|
||||
* @returns {object} Done step data
|
||||
*
|
||||
*/
|
||||
export function wait(title, message) {
|
||||
return next(NEXT_WAIT, {
|
||||
title: title,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data for a Prompt step of the wizard
|
||||
*
|
||||
* @param {string} title Title of the prompt
|
||||
* @param {string} message Message of the prompt
|
||||
* @param {string} actionText Text of the action (button)
|
||||
* @param {function} respond Respond callback
|
||||
* @param {function} cancel cancel handler
|
||||
* @param {object} inputs Input field objects
|
||||
*
|
||||
* @returns {object} Prompt step data
|
||||
*
|
||||
*/
|
||||
export function prompt(title, message, actionText, respond, cancel, inputs) {
|
||||
return next(NEXT_PROMPT, {
|
||||
title: title,
|
||||
message: message,
|
||||
actionText: actionText,
|
||||
inputs: inputs,
|
||||
respond: respond,
|
||||
cancel: cancel,
|
||||
});
|
||||
}
|
||||
|
||||
class Next {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data Step data
|
||||
*/
|
||||
constructor(data) {
|
||||
this.t = data.type();
|
||||
this.d = data.data();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return step type
|
||||
*
|
||||
* @returns {string} Step type
|
||||
*/
|
||||
type() {
|
||||
return this.t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return step data
|
||||
*
|
||||
* @returns {Done|Prompt} Step data
|
||||
*
|
||||
* @throws {Exception} When the step type is unknown
|
||||
*
|
||||
*/
|
||||
data() {
|
||||
switch (this.type()) {
|
||||
case NEXT_PROMPT:
|
||||
return new Prompt(this.d);
|
||||
|
||||
case NEXT_WAIT:
|
||||
return new Wait(this.d);
|
||||
|
||||
case NEXT_DONE:
|
||||
return new Done(this.d);
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown data type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Wizard {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} built Command executer
|
||||
* @param {subscribe.Subscribe} subs Wizard step subscriber
|
||||
* @param {function} done Callback which will be called when the wizard
|
||||
* is done
|
||||
*
|
||||
*/
|
||||
constructor(built, subs, done) {
|
||||
this.built = built;
|
||||
this.subs = subs;
|
||||
this.done = done;
|
||||
this.closed = false;
|
||||
|
||||
this.built.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Next step
|
||||
*
|
||||
* @returns {Next} Next step
|
||||
*
|
||||
* @throws {Exception} When wizard is closed
|
||||
*
|
||||
*/
|
||||
async next() {
|
||||
if (this.closed) {
|
||||
throw new Exception("Wizard already closed, no next step is available");
|
||||
}
|
||||
|
||||
let n = await this.subs.subscribe();
|
||||
|
||||
if (n.type() === NEXT_DONE) {
|
||||
this.close();
|
||||
this.done(n);
|
||||
}
|
||||
|
||||
return new Next(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether or not the command is started
|
||||
*
|
||||
* @returns {boolean} True when the command already started, false otherwise
|
||||
*
|
||||
*/
|
||||
started() {
|
||||
return this.built.started();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the name of the control info of current wizard
|
||||
*
|
||||
* @returns {object}
|
||||
*
|
||||
*/
|
||||
control() {
|
||||
return this.built.control();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close current wizard
|
||||
*
|
||||
* @returns {any} Close result
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
|
||||
return this.built.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class Info {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Builder} info Builder info
|
||||
*
|
||||
*/
|
||||
constructor(info) {
|
||||
this.type = info.name();
|
||||
this.info = info.description();
|
||||
this.tcolor = info.color();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command name
|
||||
*
|
||||
* @returns {string} Command name
|
||||
*
|
||||
*/
|
||||
name() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command description
|
||||
*
|
||||
* @returns {string} Command description
|
||||
*
|
||||
*/
|
||||
description() {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the theme color of the command
|
||||
*
|
||||
* @returns {string} Command name
|
||||
*
|
||||
*/
|
||||
color() {
|
||||
return this.tcolor;
|
||||
}
|
||||
}
|
||||
|
||||
class Builder {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} command Command builder
|
||||
*
|
||||
*/
|
||||
constructor(command) {
|
||||
this.cid = command.id();
|
||||
this.represeter = (n) => {
|
||||
return command.represet(n);
|
||||
};
|
||||
this.wizarder = (n, i, r, u, y, x, l, p) => {
|
||||
return command.wizard(n, i, r, u, y, x, l, p);
|
||||
};
|
||||
this.executer = (n, i, r, u, y, x, l, p) => {
|
||||
return command.execute(n, i, r, u, y, x, l, p);
|
||||
};
|
||||
this.launchCmd = (n, i, r, u, y, x) => {
|
||||
return command.launch(n, i, r, u, y, x);
|
||||
};
|
||||
this.launcherCmd = (c) => {
|
||||
return command.launcher(c);
|
||||
};
|
||||
this.type = command.name();
|
||||
this.info = command.description();
|
||||
this.tcolor = command.color();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the command ID
|
||||
*
|
||||
* @returns {number} Command ID
|
||||
*
|
||||
*/
|
||||
id() {
|
||||
return this.cid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command name
|
||||
*
|
||||
* @returns {string} Command name
|
||||
*
|
||||
*/
|
||||
name() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return command description
|
||||
*
|
||||
* @returns {string} Command description
|
||||
*
|
||||
*/
|
||||
description() {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the theme color of the command
|
||||
*
|
||||
* @returns {string} Command name
|
||||
*
|
||||
*/
|
||||
color() {
|
||||
return this.tcolor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an automatic command wizard
|
||||
*
|
||||
* @param {stream.Streams} streams
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
* @param {presets.Preset} preset
|
||||
* @param {object} session
|
||||
* @param {Array<string>} keptSessions
|
||||
* @param {function} done Callback which will be called when wizard is done
|
||||
*
|
||||
* @returns {Wizard} Command wizard
|
||||
*
|
||||
*/
|
||||
wizard(streams, controls, history, preset, session, keptSessions, done) {
|
||||
let subs = new subscribe.Subscribe();
|
||||
|
||||
return new Wizard(
|
||||
this.wizarder(
|
||||
new Info(this),
|
||||
preset,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
),
|
||||
subs,
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an automatic command wizard
|
||||
*
|
||||
* @param {stream.Streams} streams
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
* @param {object} config
|
||||
* @param {object} session
|
||||
* @param {Array<string>} keptSessions
|
||||
* @param {function} done Callback which will be called when wizard is done
|
||||
*
|
||||
* @returns {Wizard} Command wizard
|
||||
*
|
||||
*/
|
||||
execute(streams, controls, history, config, session, keptSessions, done) {
|
||||
let subs = new subscribe.Subscribe();
|
||||
|
||||
return new Wizard(
|
||||
this.executer(
|
||||
new Info(this),
|
||||
config,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
),
|
||||
subs,
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch command wizard out of given launcher string
|
||||
*
|
||||
* @param {stream.Streams} streams
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
* @param {string} launcher Launcher format
|
||||
* @param {function} done Callback which will be called when launching is done
|
||||
*
|
||||
* @returns {Wizard} Command wizard
|
||||
*
|
||||
*/
|
||||
launch(streams, controls, history, launcher, done) {
|
||||
let subs = new subscribe.Subscribe();
|
||||
|
||||
return new Wizard(
|
||||
this.launchCmd(
|
||||
new Info(this),
|
||||
decodeURI(launcher),
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
),
|
||||
subs,
|
||||
done,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build launcher string out of given config
|
||||
*
|
||||
* @param {object} config Configuration object
|
||||
*
|
||||
* @return {string} Launcher string
|
||||
*/
|
||||
launcher(config) {
|
||||
return this.name() + ":" + encodeURI(this.launcherCmd(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconfigure the preset data for the command wizard
|
||||
*
|
||||
* @param {presets.Preset} n preset
|
||||
*
|
||||
* @return {presets.Preset} modified new preset
|
||||
*/
|
||||
represet(n) {
|
||||
return this.represeter(n);
|
||||
}
|
||||
}
|
||||
|
||||
export class Preset {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {presets.Preset} preset preset
|
||||
* @param {Builder} command executor
|
||||
*
|
||||
*/
|
||||
constructor(preset, command) {
|
||||
this.preset = preset;
|
||||
this.command = command;
|
||||
}
|
||||
}
|
||||
|
||||
export class Commands {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Array<object>} commands Command array
|
||||
*
|
||||
*/
|
||||
constructor(commands) {
|
||||
this.commands = [];
|
||||
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
this.commands.push(new Builder(commands[i]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all commands
|
||||
*
|
||||
* @returns {Array<Builder>} A group of command
|
||||
*
|
||||
*/
|
||||
all() {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one command
|
||||
*
|
||||
* @param {number} id Command ID
|
||||
*
|
||||
* @returns {Builder} Command builder
|
||||
*
|
||||
*/
|
||||
select(id) {
|
||||
return this.commands[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns presets with merged command
|
||||
*
|
||||
* @param {presets.Presets} ps
|
||||
*
|
||||
* @returns {Array<Preset>}
|
||||
*
|
||||
*/
|
||||
mergePresets(ps) {
|
||||
let pp = [];
|
||||
|
||||
for (let i = 0; i < this.commands.length; i++) {
|
||||
const fetched = ps.fetch(this.commands[i].name());
|
||||
|
||||
for (let j = 0; j < fetched.length; j++) {
|
||||
pp.push(
|
||||
new Preset(this.commands[i].represet(fetched[j]), this.commands[i]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return pp;
|
||||
}
|
||||
}
|
||||
409
ui/commands/common.js
Normal file
409
ui/commands/common.js
Normal file
@ -0,0 +1,409 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as buffer from "buffer/";
|
||||
import * as iconv from "iconv-lite";
|
||||
import Exception from "./exception.js";
|
||||
|
||||
const availableEncodings = [
|
||||
"utf-8",
|
||||
"ibm866",
|
||||
"iso-8859-2",
|
||||
"iso-8859-3",
|
||||
"iso-8859-4",
|
||||
"iso-8859-5",
|
||||
"iso-8859-6",
|
||||
"iso-8859-7",
|
||||
"iso-8859-8",
|
||||
"iso-8859-10",
|
||||
"iso-8859-13",
|
||||
"iso-8859-14",
|
||||
"iso-8859-15",
|
||||
"iso-8859-16",
|
||||
"koi8-r",
|
||||
"koi8-u",
|
||||
"macintosh",
|
||||
"windows-874",
|
||||
"windows-1250",
|
||||
"windows-1251",
|
||||
"windows-1252",
|
||||
"windows-1253",
|
||||
"windows-1254",
|
||||
"windows-1255",
|
||||
"windows-1256",
|
||||
"windows-1257",
|
||||
"windows-1258",
|
||||
"gbk",
|
||||
"gb18030",
|
||||
"big5",
|
||||
"euc-jp",
|
||||
"shift-jis",
|
||||
"euc-kr",
|
||||
"utf-16be",
|
||||
"utf-16le",
|
||||
];
|
||||
|
||||
export const charsetPresets = (() => {
|
||||
let r = [];
|
||||
|
||||
for (let i in availableEncodings) {
|
||||
try {
|
||||
if (!iconv.encodingExists(availableEncodings[i])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new TextDecoder(availableEncodings[i]);
|
||||
|
||||
r.push(availableEncodings[i]);
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
})();
|
||||
|
||||
const numCharators = {
|
||||
0: true,
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
};
|
||||
|
||||
const hexCharators = {
|
||||
0: true,
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
a: true,
|
||||
b: true,
|
||||
c: true,
|
||||
d: true,
|
||||
e: true,
|
||||
f: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Test whether or not given string is all number
|
||||
*
|
||||
* @param {string} d Input data
|
||||
*
|
||||
* @returns {boolean} Return true if given string is all number, false otherwise
|
||||
*
|
||||
*/
|
||||
export function isNumber(d) {
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
if (!numCharators[d[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether or not given string is all hex
|
||||
*
|
||||
* @param {string} d Input data
|
||||
*
|
||||
* @returns {boolean} Return true if given string is all hex, false otherwise
|
||||
*
|
||||
*/
|
||||
export function isHex(d) {
|
||||
let dd = d.toLowerCase();
|
||||
|
||||
for (let i = 0; i < dd.length; i++) {
|
||||
if (!hexCharators[dd[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether or not given string is a valid hostname as far as the Sshwifty
|
||||
* client consider. This function will return true if the string contains only
|
||||
* printable charactors
|
||||
*
|
||||
* @param {string} d Input data
|
||||
*
|
||||
* @returns {boolean} Return true if given string is all hex, false otherwise
|
||||
*
|
||||
*/
|
||||
function isHostname(d) {
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
const dChar = d.charCodeAt(i);
|
||||
|
||||
if (dChar >= 32 && dChar <= 126) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar === 128) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar >= 130 && dChar <= 140) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar === 142) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar >= 145 && dChar <= 156) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar >= 158 && dChar <= 159) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dChar >= 161 && dChar <= 255) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IPv4 address
|
||||
*
|
||||
* @param {string} d IP address
|
||||
*
|
||||
* @returns {Uint8Array} Parsed IPv4 Address
|
||||
*
|
||||
* @throws {Exception} When the given ip address was not an IPv4 addr
|
||||
*
|
||||
*/
|
||||
export function parseIPv4(d) {
|
||||
const addrSeg = 4;
|
||||
|
||||
let s = d.split(".");
|
||||
|
||||
if (s.length != addrSeg) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
let r = new Uint8Array(addrSeg);
|
||||
|
||||
for (let i in s) {
|
||||
if (!isNumber(s[i])) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
let ii = parseInt(s[i], 10); // Only support dec
|
||||
|
||||
if (isNaN(ii)) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
if (ii > 0xff) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
r[i] = ii;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IPv6 address. ::ffff: notation is NOT supported
|
||||
*
|
||||
* @param {string} d IP address
|
||||
*
|
||||
* @returns {Uint8Array} Parsed IPv6 Address
|
||||
*
|
||||
* @throws {Exception} When the given ip address was not an IPv6 addr
|
||||
*
|
||||
*/
|
||||
export function parseIPv6(d) {
|
||||
const addrSeg = 8;
|
||||
let s = d.split(":");
|
||||
|
||||
if (s.length > addrSeg || s.length <= 1) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
if (s[0].charAt(0) === "[") {
|
||||
s[0] = s[0].substring(1, s[0].length);
|
||||
let end = s.length - 1;
|
||||
if (s[end].charAt(s[end].length - 1) !== "]") {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
s[end] = s[end].substring(0, s[end].length - 1);
|
||||
}
|
||||
|
||||
let r = new Uint8Array(addrSeg * 2),
|
||||
rIndexShift = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i].length <= 0) {
|
||||
rIndexShift = addrSeg - s.length;
|
||||
continue;
|
||||
}
|
||||
if (!isHex(s[i])) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
let ii = parseInt(s[i], 16); // Only support hex
|
||||
if (isNaN(ii)) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
if (ii > 0xffff) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
let j = (rIndexShift + i) * 2;
|
||||
r[j] = ii >> 8;
|
||||
r[j + 1] = ii & 0xff;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string into a {Uint8Array}
|
||||
*
|
||||
* @param {string} d Input
|
||||
*
|
||||
* @returns {Uint8Array} Output
|
||||
*
|
||||
*/
|
||||
export function strToUint8Array(d) {
|
||||
let r = new Uint8Array(d.length);
|
||||
|
||||
for (let i = 0, j = d.length; i < j; i++) {
|
||||
r[i] = d.charCodeAt(i);
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string into a binary {Uint8Array}
|
||||
*
|
||||
* @param {string} d Input
|
||||
*
|
||||
* @returns {Uint8Array} Output
|
||||
*
|
||||
*/
|
||||
export function strToBinary(d) {
|
||||
return new Uint8Array(buffer.Buffer.from(d, "binary").buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IPv6 address. ::ffff: notation is NOT supported
|
||||
*
|
||||
* @param {string} d IP address
|
||||
*
|
||||
* @returns {Uint8Array} Parsed IPv6 Address
|
||||
*
|
||||
* @throws {Exception} When the given ip address was not an IPv6 addr
|
||||
*
|
||||
*/
|
||||
export function parseHostname(d) {
|
||||
if (d.length <= 0) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
if (!isHostname(d)) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
return strToUint8Array(d);
|
||||
}
|
||||
|
||||
function parseIP(d) {
|
||||
try {
|
||||
return {
|
||||
type: "IPv4",
|
||||
data: parseIPv4(d),
|
||||
};
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
type: "IPv6",
|
||||
data: new Uint8Array(parseIPv6(d).buffer),
|
||||
};
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
return {
|
||||
type: "Hostname",
|
||||
data: parseHostname(d),
|
||||
};
|
||||
}
|
||||
|
||||
export function splitHostPort(d, defPort) {
|
||||
let hps = d.lastIndexOf(":"),
|
||||
fhps = d.indexOf(":"),
|
||||
ipv6hps = d.indexOf("[");
|
||||
|
||||
if ((hps < 0 || hps != fhps) && ipv6hps < 0) {
|
||||
let a = parseIP(d);
|
||||
|
||||
return {
|
||||
type: a.type,
|
||||
addr: a.data,
|
||||
port: defPort,
|
||||
};
|
||||
}
|
||||
|
||||
if (ipv6hps > 0) {
|
||||
throw new Exception("Invalid address");
|
||||
} else if (ipv6hps === 0) {
|
||||
let ipv6hpse = d.lastIndexOf("]");
|
||||
|
||||
if (ipv6hpse <= ipv6hps || ipv6hpse + 1 != hps) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
}
|
||||
|
||||
let addr = d.slice(0, hps),
|
||||
port = d.slice(hps + 1, d.length);
|
||||
|
||||
if (!isNumber(port)) {
|
||||
throw new Exception("Invalid address");
|
||||
}
|
||||
|
||||
let portNum = parseInt(port, 10),
|
||||
a = parseIP(addr);
|
||||
|
||||
return {
|
||||
type: a.type,
|
||||
addr: a.data,
|
||||
port: portNum,
|
||||
};
|
||||
}
|
||||
246
ui/commands/common_test.js
Normal file
246
ui/commands/common_test.js
Normal file
@ -0,0 +1,246 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
import * as common from "./common.js";
|
||||
|
||||
describe("Common", () => {
|
||||
it("parseIPv4", () => {
|
||||
let tests = [
|
||||
{
|
||||
sample: "127.0.0.1",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([127, 0, 0, 1]),
|
||||
},
|
||||
{
|
||||
sample: "255.255.255.255",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([255, 255, 255, 255]),
|
||||
},
|
||||
{
|
||||
sample: "255.255.a.255",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
sample: "255.255.255",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
sample: "2001:db8:1f70::999:de8:7648:6e8",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
sample: "a.ssh.vaguly.com",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (let i in tests) {
|
||||
if (tests[i].expectingFailure) {
|
||||
let ee = null;
|
||||
|
||||
try {
|
||||
common.parseIPv4(tests[i].sample);
|
||||
} catch (e) {
|
||||
ee = e;
|
||||
}
|
||||
|
||||
assert.notStrictEqual(ee, null, "Test " + tests[i].sample);
|
||||
} else {
|
||||
let data = common.parseIPv4(tests[i].sample);
|
||||
|
||||
assert.deepStrictEqual(data, tests[i].expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parseIPv6", () => {
|
||||
let tests = [
|
||||
{
|
||||
sample: "2001:db8:1f70:0:999:de8:7648:6e8",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
|
||||
0x76, 0x48, 0x6, 0xe8,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "2001:db8:85a3::8a2e:370:7334",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x85, 0xa3, 0x0, 0x0, 0x0, 0x0, 0x8a, 0x2e,
|
||||
0x3, 0x70, 0x73, 0x34,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "fdef:90fb:4138::8ca",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0xfd, 0xef, 0x90, 0xfb, 0x41, 0x38, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x8, 0xca,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "::1",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x1,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "::",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "2001:db8:1f70::999:de8:7648:6e8",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
|
||||
0x76, 0x48, 0x6, 0xe8,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "2001:0db8:ac10:fe01::",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x20, 0x01, 0x0d, 0xb8, 0xac, 0x10, 0xfe, 0x01, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0, 0x0, 0x0,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "::7f00:1",
|
||||
expectingFailure: false,
|
||||
expected: new Uint8Array([
|
||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7f,
|
||||
0x00, 0x00, 0x01,
|
||||
]),
|
||||
},
|
||||
{
|
||||
sample: "127.0.0.1",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
sample: "255.255.255.255",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
sample: "255.255.a.255",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
sample: "255.255.255",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
sample: "a.ssh.vaguly.com",
|
||||
expectingFailure: true,
|
||||
expected: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (let i in tests) {
|
||||
if (tests[i].expectingFailure) {
|
||||
let ee = null;
|
||||
|
||||
try {
|
||||
common.parseIPv6(tests[i].sample);
|
||||
} catch (e) {
|
||||
ee = e;
|
||||
}
|
||||
|
||||
assert.notStrictEqual(ee, null, "Test " + tests[i].sample);
|
||||
} else {
|
||||
let data = common.parseIPv6(tests[i].sample);
|
||||
|
||||
assert.deepStrictEqual(data, tests[i].expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("splitHostPort", () => {
|
||||
let tests = [
|
||||
// Host name
|
||||
{
|
||||
sample: "ssh.vaguly.com",
|
||||
expectedType: "Hostname",
|
||||
expectedAddr: common.strToUint8Array("ssh.vaguly.com"),
|
||||
expectedPort: 22,
|
||||
},
|
||||
{
|
||||
sample: "ssh.vaguly.com:22",
|
||||
expectedType: "Hostname",
|
||||
expectedAddr: common.strToUint8Array("ssh.vaguly.com"),
|
||||
expectedPort: 22,
|
||||
},
|
||||
|
||||
// IPv4
|
||||
{
|
||||
sample: "10.220.179.110",
|
||||
expectedType: "IPv4",
|
||||
expectedAddr: new Uint8Array([10, 220, 179, 110]),
|
||||
expectedPort: 22,
|
||||
},
|
||||
{
|
||||
sample: "10.220.179.110:3333",
|
||||
expectedType: "IPv4",
|
||||
expectedAddr: new Uint8Array([10, 220, 179, 110]),
|
||||
expectedPort: 3333,
|
||||
},
|
||||
|
||||
// IPv6
|
||||
{
|
||||
sample: "2001:db8:1f70::999:de8:7648:6e8",
|
||||
expectedType: "IPv6",
|
||||
expectedAddr: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
|
||||
0x76, 0x48, 0x6, 0xe8,
|
||||
]),
|
||||
expectedPort: 22,
|
||||
},
|
||||
{
|
||||
sample: "[2001:db8:1f70::999:de8:7648:6e8]:100",
|
||||
expectedType: "IPv6",
|
||||
expectedAddr: new Uint8Array([
|
||||
0x20, 0x01, 0xd, 0xb8, 0x1f, 0x70, 0x0, 0x0, 0x9, 0x99, 0xd, 0xe8,
|
||||
0x76, 0x48, 0x6, 0xe8,
|
||||
]),
|
||||
expectedPort: 100,
|
||||
},
|
||||
];
|
||||
|
||||
for (let i in tests) {
|
||||
let hostport = common.splitHostPort(tests[i].sample, 22);
|
||||
|
||||
assert.deepStrictEqual(hostport.type, tests[i].expectedType);
|
||||
assert.deepStrictEqual(hostport.addr, tests[i].expectedAddr);
|
||||
assert.strictEqual(hostport.port, tests[i].expectedPort);
|
||||
}
|
||||
});
|
||||
});
|
||||
60
ui/commands/controls.js
vendored
Normal file
60
ui/commands/controls.js
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
|
||||
export class Controls {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {[]object} controls
|
||||
*
|
||||
* @throws {Exception} When control type already been defined
|
||||
*
|
||||
*/
|
||||
constructor(controls) {
|
||||
this.controls = {};
|
||||
|
||||
for (let i in controls) {
|
||||
let cType = controls[i].type();
|
||||
|
||||
if (typeof this.controls[cType] === "object") {
|
||||
throw new Exception('Control "' + cType + '" already been defined');
|
||||
}
|
||||
|
||||
this.controls[cType] = controls[i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a control
|
||||
*
|
||||
* @param {string} type Type of the control
|
||||
*
|
||||
* @returns {object} Control object
|
||||
*
|
||||
* @throws {Exception} When given control type is undefined
|
||||
*
|
||||
*/
|
||||
get(type) {
|
||||
if (typeof this.controls[type] !== "object") {
|
||||
throw new Exception('Control "' + type + '" was undefined');
|
||||
}
|
||||
|
||||
return this.controls[type];
|
||||
}
|
||||
}
|
||||
106
ui/commands/events.js
Normal file
106
ui/commands/events.js
Normal file
@ -0,0 +1,106 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
|
||||
export class Events {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {[]string} events required events
|
||||
* @param {object} callbacks Callbacks
|
||||
*
|
||||
* @throws {Exception} When event handler is not registered
|
||||
*
|
||||
*/
|
||||
constructor(events, callbacks) {
|
||||
this.events = {};
|
||||
this.placeHolders = {};
|
||||
|
||||
for (let i in events) {
|
||||
if (typeof callbacks[events[i]] !== "function") {
|
||||
throw new Exception(
|
||||
'Unknown event type for "' +
|
||||
events[i] +
|
||||
'". Expecting "function" got "' +
|
||||
typeof callbacks[events[i]] +
|
||||
'" instead.',
|
||||
);
|
||||
}
|
||||
|
||||
let name = events[i];
|
||||
|
||||
if (name.indexOf("@") === 0) {
|
||||
name = name.substring(1);
|
||||
|
||||
this.placeHolders[name] = null;
|
||||
}
|
||||
|
||||
this.events[name] = callbacks[events[i]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Place callbacks to pending placeholder events
|
||||
*
|
||||
* @param {string} type Event Type
|
||||
* @param {function} callback Callback function
|
||||
*/
|
||||
place(type, callback) {
|
||||
if (this.placeHolders[type] !== null) {
|
||||
throw new Exception(
|
||||
'Event type "' +
|
||||
type +
|
||||
'" cannot be appended. It maybe ' +
|
||||
"unregistered or already been acquired",
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof callback !== "function") {
|
||||
throw new Exception(
|
||||
'Unknown event type for "' +
|
||||
type +
|
||||
'". Expecting "function" got "' +
|
||||
typeof callback +
|
||||
'" instead.',
|
||||
);
|
||||
}
|
||||
|
||||
delete this.placeHolders[type];
|
||||
|
||||
this.events[type] = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an event
|
||||
*
|
||||
* @param {string} type Event type
|
||||
* @param {...any} data Event data
|
||||
*
|
||||
* @returns {any} The result of the event handler
|
||||
*
|
||||
* @throws {Exception} When event type is not registered
|
||||
*
|
||||
*/
|
||||
fire(type, ...data) {
|
||||
if (!this.events[type] && this.placeHolders[type] !== null) {
|
||||
throw new Exception("Unknown event type: " + type);
|
||||
}
|
||||
|
||||
return this.events[type](...data);
|
||||
}
|
||||
}
|
||||
28
ui/commands/exception.js
Normal file
28
ui/commands/exception.js
Normal file
@ -0,0 +1,28 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
export default class Exception extends Error {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {string} message error message
|
||||
*
|
||||
*/
|
||||
constructor(message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
311
ui/commands/history.js
Normal file
311
ui/commands/history.js
Normal file
@ -0,0 +1,311 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as command from "./commands.js";
|
||||
|
||||
/**
|
||||
* Extract needed data
|
||||
*
|
||||
* @param {Array<string>} kept The keys of of the data to be kept
|
||||
* @param {object} input Input data
|
||||
*
|
||||
* @return {object} Extracted data
|
||||
*/
|
||||
function extractSelectedData(kept, input) {
|
||||
if (!kept || typeof kept !== "object" || kept.length < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = {},
|
||||
length = 0;
|
||||
|
||||
for (let k in kept) {
|
||||
if (!input[kept[k]]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
data[kept[k]] = input[kept[k]];
|
||||
length++;
|
||||
}
|
||||
|
||||
if (length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} data to be searched
|
||||
* @param {string} metaName name of the meta
|
||||
* @param {string} valContains target string to search
|
||||
*
|
||||
*/
|
||||
function metaContains(data, metaName, valContains) {
|
||||
switch (typeof data[metaName]) {
|
||||
case "string":
|
||||
return data[metaName].indexOf(valContains) >= 0;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class History {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Array<object>} records
|
||||
* @param {function} saver
|
||||
* @param {number} maxItems
|
||||
*
|
||||
*/
|
||||
constructor(records, saver, maxItems) {
|
||||
this.records = records;
|
||||
this.maxItems = maxItems;
|
||||
this.saver = saver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the index of given uname, or -1 when not found
|
||||
*
|
||||
* @param {string} uname the unique name
|
||||
*
|
||||
* @returns {integer} The index of given uname
|
||||
*
|
||||
*/
|
||||
indexOf(uname) {
|
||||
for (let i in this.records) {
|
||||
if (this.records[i].uname !== uname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save record to history
|
||||
*
|
||||
* @param {string} uname unique name
|
||||
* @param {string} title Title
|
||||
* @param {command.Info} info Command info
|
||||
* @param {Date} lastUsed Last used
|
||||
* @param {object} data Data
|
||||
* @param {object} sessionData Data which only available for current session
|
||||
* @param {Array<string>} keptSessions Keys of the session data that should
|
||||
* be saved
|
||||
*
|
||||
*/
|
||||
save(uname, title, lastUsed, info, data, sessionData, keptSessions) {
|
||||
const unameIdx = this.indexOf(uname);
|
||||
|
||||
if (unameIdx >= 0) {
|
||||
this.records.splice(unameIdx, 1);
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
uname: uname,
|
||||
title: title,
|
||||
type: info.name(),
|
||||
color: info.color(),
|
||||
last: lastUsed.getTime(),
|
||||
data: data,
|
||||
session: sessionData,
|
||||
keptSessions: keptSessions,
|
||||
});
|
||||
|
||||
if (this.records.length > this.maxItems) {
|
||||
this.records = this.records.slice(
|
||||
this.records.length - this.maxItems,
|
||||
this.records.length,
|
||||
);
|
||||
}
|
||||
|
||||
this.store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current records to storage
|
||||
*
|
||||
*/
|
||||
store() {
|
||||
this.saver(this, this.export());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete record from history
|
||||
*
|
||||
* @param {string} uid unique name
|
||||
*
|
||||
*/
|
||||
del(uid) {
|
||||
for (let i in this.records) {
|
||||
if (this.records[i].uname !== uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.records.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
this.saver(this, this.records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session data
|
||||
*
|
||||
* @param {string} uid unique name
|
||||
*
|
||||
*/
|
||||
clearSession(uid) {
|
||||
for (let i in this.records) {
|
||||
if (this.records[i].uname !== uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.records[i].session = null;
|
||||
this.records[i].keptSessions = [];
|
||||
break;
|
||||
}
|
||||
|
||||
this.store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all history records. The exported data is differ than the
|
||||
* internal ones, it cannot be directly import back
|
||||
*
|
||||
* @returns {Array<object>} Records
|
||||
*
|
||||
*/
|
||||
all() {
|
||||
let r = [];
|
||||
|
||||
for (let i in this.records) {
|
||||
r.push({
|
||||
uid: this.records[i].uname,
|
||||
title: this.records[i].title,
|
||||
type: this.records[i].type,
|
||||
color: this.records[i].color,
|
||||
last: new Date(this.records[i].last),
|
||||
data: this.records[i].data,
|
||||
session: this.records[i].session,
|
||||
keptSessions: this.records[i].keptSessions,
|
||||
});
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current history records
|
||||
*
|
||||
* @returns {Array<object>} Records
|
||||
*
|
||||
*/
|
||||
export() {
|
||||
let r = [];
|
||||
|
||||
for (let i in this.records) {
|
||||
r.push({
|
||||
uname: this.records[i].uname,
|
||||
title: this.records[i].title,
|
||||
type: this.records[i].type,
|
||||
color: this.records[i].color,
|
||||
last: this.records[i].last,
|
||||
data: this.records[i].data,
|
||||
session: extractSelectedData(
|
||||
this.records[i].keptSessions,
|
||||
this.records[i].session,
|
||||
),
|
||||
keptSessions: this.records[i].keptSessions,
|
||||
});
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import data into current history records
|
||||
*
|
||||
* @param {Array<object>} records Records
|
||||
*
|
||||
*/
|
||||
import(records) {
|
||||
for (let i in records) {
|
||||
if (this.indexOf(records[i].uname) >= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
uname: records[i].uname,
|
||||
title: records[i].title,
|
||||
type: records[i].type,
|
||||
color: records[i].color,
|
||||
last: records[i].last,
|
||||
data: records[i].data,
|
||||
session: extractSelectedData(
|
||||
records[i].keptSessions,
|
||||
records[i].session,
|
||||
),
|
||||
keptSessions: records[i].keptSessions,
|
||||
});
|
||||
}
|
||||
|
||||
this.store();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for partly matched results
|
||||
*
|
||||
* @param {string} type of the history record
|
||||
* @param {string} metaName name of the meta data
|
||||
* @param {string} keyword keyword to search
|
||||
* @param {number} max max results
|
||||
*/
|
||||
search(type, metaName, keyword, max) {
|
||||
let maxResults = max > this.records.length ? this.records.length : max;
|
||||
let s = [];
|
||||
|
||||
if (maxResults < 0) {
|
||||
maxResults = this.records.length;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.records.length && s.length < maxResults; i++) {
|
||||
if (this.records[i].type !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.records[i].data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!metaContains(this.records[i].data, metaName, keyword)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
s.push(this.records[i]);
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
}
|
||||
90
ui/commands/integer.js
Normal file
90
ui/commands/integer.js
Normal file
@ -0,0 +1,90 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as reader from "../stream/reader.js";
|
||||
import Exception from "./exception.js";
|
||||
|
||||
export const MAX = 0x3fff;
|
||||
export const MAX_BYTES = 2;
|
||||
|
||||
const integerHasNextBit = 0x80;
|
||||
const integerValueCutter = 0x7f;
|
||||
|
||||
export class Integer {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {number} num Integer number
|
||||
*
|
||||
*/
|
||||
constructor(num) {
|
||||
this.num = num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marshal integer to buffer
|
||||
*
|
||||
* @returns {Uint8Array} Integer buffer
|
||||
*
|
||||
* @throws {Exception} When number is too large
|
||||
*
|
||||
*/
|
||||
marshal() {
|
||||
if (this.num > MAX) {
|
||||
throw new Exception("Integer number cannot be greater than 0x3fff");
|
||||
}
|
||||
|
||||
if (this.num <= integerValueCutter) {
|
||||
return new Uint8Array([this.num & integerValueCutter]);
|
||||
}
|
||||
|
||||
return new Uint8Array([
|
||||
(this.num >> 7) | integerHasNextBit,
|
||||
this.num & integerValueCutter,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the reader to build an Integer
|
||||
*
|
||||
* @param {reader.Reader} rd Data reader
|
||||
*
|
||||
*/
|
||||
async unmarshal(rd) {
|
||||
for (let i = 0; i < MAX_BYTES; i++) {
|
||||
let r = await reader.readOne(rd);
|
||||
|
||||
this.num |= r[0] & integerValueCutter;
|
||||
|
||||
if ((integerHasNextBit & r[0]) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.num <<= 7;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value of the number
|
||||
*
|
||||
* @returns {number} The integer value
|
||||
*
|
||||
*/
|
||||
value() {
|
||||
return this.num;
|
||||
}
|
||||
}
|
||||
60
ui/commands/integer_test.js
Normal file
60
ui/commands/integer_test.js
Normal file
@ -0,0 +1,60 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as integer from "./integer.js";
|
||||
|
||||
describe("Integer", () => {
|
||||
it("Integer 127", async () => {
|
||||
let i = new integer.Integer(127),
|
||||
marshalled = i.marshal();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
assert.strictEqual(marshalled.length, 1);
|
||||
|
||||
r.feed(marshalled);
|
||||
|
||||
let i2 = new integer.Integer(0);
|
||||
|
||||
await i2.unmarshal(r);
|
||||
|
||||
assert.strictEqual(i.value(), i2.value());
|
||||
});
|
||||
|
||||
it("Integer MAX", async () => {
|
||||
let i = new integer.Integer(integer.MAX),
|
||||
marshalled = i.marshal();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
assert.strictEqual(marshalled.length, 2);
|
||||
|
||||
r.feed(marshalled);
|
||||
|
||||
let i2 = new integer.Integer(0);
|
||||
|
||||
await i2.unmarshal(r);
|
||||
|
||||
assert.strictEqual(i.value(), i2.value());
|
||||
});
|
||||
});
|
||||
327
ui/commands/presets.js
Normal file
327
ui/commands/presets.js
Normal file
@ -0,0 +1,327 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Exception from "./exception.js";
|
||||
|
||||
/**
|
||||
* Default preset item, contains data of a default preset
|
||||
*
|
||||
*/
|
||||
const presetItem = {
|
||||
title: "",
|
||||
type: "",
|
||||
host: "",
|
||||
meta: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify Preset Item Meta
|
||||
*
|
||||
* @param {object} preset
|
||||
*
|
||||
*/
|
||||
function verifyPresetItemMeta(preset) {
|
||||
for (let i in preset.meta) {
|
||||
if (typeof preset.meta[i] === "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
'The data type of meta field "' +
|
||||
i +
|
||||
'" was "' +
|
||||
typeof preset.meta[i] +
|
||||
'" instead of expected "string"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and verify the given preset, return a valid preset
|
||||
*
|
||||
* @param {object} item
|
||||
*
|
||||
* @throws {Exception} when invalid data is given
|
||||
*
|
||||
* @return {object}
|
||||
*
|
||||
*/
|
||||
function parsePresetItem(item) {
|
||||
let preset = {};
|
||||
|
||||
for (let i in presetItem) {
|
||||
preset[i] = presetItem[i];
|
||||
}
|
||||
|
||||
for (let i in presetItem) {
|
||||
if (typeof presetItem[i] === typeof item[i]) {
|
||||
preset[i] = item[i];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
'Expecting the data type of "' +
|
||||
i +
|
||||
'" is "' +
|
||||
typeof presetItem[i] +
|
||||
'", given "' +
|
||||
typeof item[i] +
|
||||
'" instead',
|
||||
);
|
||||
}
|
||||
|
||||
verifyPresetItemMeta(preset.meta);
|
||||
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset data
|
||||
*
|
||||
*/
|
||||
export class Preset {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {object} preset preset data
|
||||
*
|
||||
*/
|
||||
constructor(preset) {
|
||||
this.preset = parsePresetItem(preset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the title of the preset
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
title() {
|
||||
return this.preset.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the type of the preset
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
type() {
|
||||
return this.preset.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the host of the preset
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
host() {
|
||||
return this.preset.host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the given meta of current preset
|
||||
*
|
||||
* @param {string} name name of the meta data
|
||||
*
|
||||
* @throws {Exception} when invalid data is given
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
meta(name) {
|
||||
if (typeof this.preset.meta[name] !== "string") {
|
||||
throw new Exception('Meta "' + name + '" was undefined');
|
||||
}
|
||||
|
||||
return this.preset.meta[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the given meta of current preset, and if failed, return the given
|
||||
* default value
|
||||
*
|
||||
* @param {string} name name of the meta data
|
||||
* @param {string} defaultValue default value to be returned when the meta was
|
||||
* not found
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
metaDefault(name, defaultValue) {
|
||||
try {
|
||||
return this.meta(name);
|
||||
} catch (e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert new meta item
|
||||
*
|
||||
* @param {string} name name of the meta data
|
||||
* @param {string} data data of the meta data
|
||||
*
|
||||
* @throws {Exception} when invalid data is given
|
||||
*
|
||||
*/
|
||||
insertMeta(name, data) {
|
||||
if (typeof this.preset.meta[name] !== "undefined") {
|
||||
throw new Exception('Meta "' + name + '" has already been defined');
|
||||
}
|
||||
|
||||
this.preset.meta[name] = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all meta keys
|
||||
*
|
||||
* @returns {Array<string>} All meta keys
|
||||
*
|
||||
*/
|
||||
metaKeys() {
|
||||
let keys = [];
|
||||
|
||||
for (let k in this.preset.meta) {
|
||||
keys.push(k);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an empty preset
|
||||
*
|
||||
* @returns {Preset}
|
||||
*
|
||||
*/
|
||||
export function emptyPreset() {
|
||||
return new Preset({
|
||||
title: "Default",
|
||||
type: "Default",
|
||||
host: "",
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Preset manager
|
||||
*
|
||||
*/
|
||||
export class Presets {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Array<object>} presets Array of preset data
|
||||
*
|
||||
*/
|
||||
constructor(presets) {
|
||||
this.presets = [];
|
||||
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
this.presets.push(new Preset(presets[i]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all presets of a type
|
||||
*
|
||||
* @param {string} type type of the presets data
|
||||
*
|
||||
* @returns {Array<Preset>}
|
||||
*
|
||||
*/
|
||||
fetch(type) {
|
||||
let presets = [];
|
||||
|
||||
for (let i = 0; i < this.presets.length; i++) {
|
||||
if (this.presets[i].type() !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
presets.push(this.presets[i]);
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return presets with matched type and meta data
|
||||
*
|
||||
* @param {string} type type of the presets data
|
||||
* @param {string} metaName name of the meta data
|
||||
* @param {string} metaVal value of the meta data
|
||||
*
|
||||
* @returns {Array<Preset>}
|
||||
*
|
||||
*/
|
||||
meta(type, metaName, metaVal) {
|
||||
let presets = [];
|
||||
|
||||
for (let i = 0; i < this.presets.length; i++) {
|
||||
if (this.presets[i].type() !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.presets[i].meta(metaName) !== metaVal) {
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof Exception)) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
presets.push(this.presets[i]);
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return presets with matched type and host
|
||||
*
|
||||
* @param {string} type type of the presets
|
||||
* @param {string} host host of the presets
|
||||
*
|
||||
* @returns {Array<Preset>}
|
||||
*
|
||||
*/
|
||||
hosts(type, host) {
|
||||
let presets = [];
|
||||
|
||||
for (let i = 0; i < this.presets.length; i++) {
|
||||
if (this.presets[i].type() !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.presets[i].host() !== host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
presets.push(this.presets[i]);
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
}
|
||||
1124
ui/commands/ssh.js
Normal file
1124
ui/commands/ssh.js
Normal file
File diff suppressed because it is too large
Load Diff
72
ui/commands/string.js
Normal file
72
ui/commands/string.js
Normal file
@ -0,0 +1,72 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as integer from "./integer.js";
|
||||
|
||||
export class String {
|
||||
/**
|
||||
* Read String from given reader
|
||||
*
|
||||
* @param {reader.Reader} rd Source reader
|
||||
*
|
||||
* @returns {String} readed string
|
||||
*
|
||||
*/
|
||||
static async read(rd) {
|
||||
let l = new integer.Integer(0);
|
||||
|
||||
await l.unmarshal(rd);
|
||||
|
||||
return new String(await reader.readN(rd, l.value()));
|
||||
}
|
||||
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {Uint8Array} str String data
|
||||
*/
|
||||
constructor(str) {
|
||||
this.str = str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the string
|
||||
*
|
||||
* @returns {Uint8Array} String data
|
||||
*
|
||||
*/
|
||||
data() {
|
||||
return this.str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return serialized String as array
|
||||
*
|
||||
* @returns {Uint8Array} serialized String
|
||||
*
|
||||
*/
|
||||
buffer() {
|
||||
let lBytes = new integer.Integer(this.str.length).marshal(),
|
||||
buf = new Uint8Array(lBytes.length + this.str.length);
|
||||
|
||||
buf.set(lBytes, 0);
|
||||
buf.set(this.str, lBytes.length);
|
||||
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
265
ui/commands/string_test.js
Normal file
265
ui/commands/string_test.js
Normal file
@ -0,0 +1,265 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import assert from "assert";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as strings from "./string.js";
|
||||
|
||||
describe("String", () => {
|
||||
it("String 1", async () => {
|
||||
let s = new strings.String(new Uint8Array(["H", "E", "L", "L", "O"])),
|
||||
sBuf = s.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(sBuf);
|
||||
|
||||
let s2 = await strings.String.read(r);
|
||||
|
||||
assert.deepStrictEqual(s2.data(), s.data());
|
||||
});
|
||||
|
||||
it("String 2", async () => {
|
||||
let s = new strings.String(
|
||||
new Uint8Array([
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
"H",
|
||||
"E",
|
||||
"L",
|
||||
"L",
|
||||
"O",
|
||||
"W",
|
||||
"O",
|
||||
"R",
|
||||
"L",
|
||||
"D",
|
||||
]),
|
||||
),
|
||||
sBuf = s.buffer();
|
||||
|
||||
let r = new reader.Reader(new reader.Multiple(() => {}), (data) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
r.feed(sBuf);
|
||||
|
||||
let s2 = await strings.String.read(r);
|
||||
|
||||
assert.deepStrictEqual(s2.data(), s.data());
|
||||
});
|
||||
});
|
||||
648
ui/commands/telnet.js
Normal file
648
ui/commands/telnet.js
Normal file
@ -0,0 +1,648 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as header from "../stream/header.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as stream from "../stream/stream.js";
|
||||
import * as address from "./address.js";
|
||||
import * as command from "./commands.js";
|
||||
import * as common from "./common.js";
|
||||
import * as controls from "./controls.js";
|
||||
import * as event from "./events.js";
|
||||
import Exception from "./exception.js";
|
||||
import * as history from "./history.js";
|
||||
import * as presets from "./presets.js";
|
||||
|
||||
const COMMAND_ID = 0x00;
|
||||
|
||||
const SERVER_INITIAL_ERROR_BAD_ADDRESS = 0x01;
|
||||
|
||||
const SERVER_REMOTE_BAND = 0x00;
|
||||
const SERVER_DIAL_FAILED = 0x01;
|
||||
const SERVER_DIAL_CONNECTED = 0x02;
|
||||
|
||||
const DEFAULT_PORT = 23;
|
||||
|
||||
const HostMaxSearchResults = 3;
|
||||
|
||||
class Telnet {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {stream.Sender} sd Stream sender
|
||||
* @param {object} config configuration
|
||||
* @param {object} callbacks Event callbacks
|
||||
*
|
||||
*/
|
||||
constructor(sd, config, callbacks) {
|
||||
this.sender = sd;
|
||||
this.config = config;
|
||||
this.connected = false;
|
||||
this.events = new event.Events(
|
||||
[
|
||||
"initialization.failed",
|
||||
"initialized",
|
||||
"connect.failed",
|
||||
"connect.succeed",
|
||||
"@inband",
|
||||
"close",
|
||||
"@completed",
|
||||
],
|
||||
callbacks,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send intial request
|
||||
*
|
||||
* @param {stream.InitialSender} initialSender Initial stream request sender
|
||||
*
|
||||
*/
|
||||
run(initialSender) {
|
||||
let addr = new address.Address(
|
||||
this.config.host.type,
|
||||
this.config.host.address,
|
||||
this.config.host.port,
|
||||
),
|
||||
addrBuf = addr.buffer();
|
||||
|
||||
let data = new Uint8Array(addrBuf.length);
|
||||
|
||||
data.set(addrBuf, 0);
|
||||
|
||||
initialSender.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive the initial stream request
|
||||
*
|
||||
* @param {header.InitialStream} streamInitialHeader Server respond on the
|
||||
* initial stream request
|
||||
*
|
||||
*/
|
||||
initialize(streamInitialHeader) {
|
||||
if (!streamInitialHeader.success()) {
|
||||
this.events.fire("initialization.failed", streamInitialHeader);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.fire("initialized", streamInitialHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick the command
|
||||
*
|
||||
* @param {header.Stream} streamHeader Stream data header
|
||||
* @param {reader.Limited} rd Data reader
|
||||
*
|
||||
* @returns {any} The result of the ticking
|
||||
*
|
||||
* @throws {Exception} When the stream header type is unknown
|
||||
*
|
||||
*/
|
||||
tick(streamHeader, rd) {
|
||||
switch (streamHeader.marker()) {
|
||||
case SERVER_DIAL_CONNECTED:
|
||||
if (!this.connected) {
|
||||
this.connected = true;
|
||||
|
||||
return this.events.fire("connect.succeed", rd, this);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_DIAL_FAILED:
|
||||
if (!this.connected) {
|
||||
return this.events.fire("connect.failed", rd);
|
||||
}
|
||||
break;
|
||||
|
||||
case SERVER_REMOTE_BAND:
|
||||
if (this.connected) {
|
||||
return this.events.fire("inband", rd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
throw new Exception("Unknown stream header marker");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send close signal to remote
|
||||
*
|
||||
*/
|
||||
sendClose() {
|
||||
return this.sender.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send data to remote
|
||||
*
|
||||
* @param {Uint8Array} data
|
||||
*
|
||||
*/
|
||||
sendData(data) {
|
||||
return this.sender.sendData(0x00, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the command
|
||||
*
|
||||
*/
|
||||
close() {
|
||||
this.sendClose();
|
||||
|
||||
return this.events.fire("close");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the command completely
|
||||
*
|
||||
*/
|
||||
completed() {
|
||||
return this.events.fire("completed");
|
||||
}
|
||||
}
|
||||
|
||||
const initialFieldDef = {
|
||||
Host: {
|
||||
name: "Host",
|
||||
description:
|
||||
"Looking for server to connect? Checkout " +
|
||||
'<a href="http://www.telnet.org/htm/places.htm" target="blank">' +
|
||||
"telnet.org</a> for public servers.",
|
||||
type: "text",
|
||||
value: "",
|
||||
example: "telnet.vaguly.com:23",
|
||||
readonly: false,
|
||||
suggestions(input) {
|
||||
return [];
|
||||
},
|
||||
verify(d) {
|
||||
if (d.length <= 0) {
|
||||
throw new Error("Hostname must be specified");
|
||||
}
|
||||
|
||||
let addr = common.splitHostPort(d, DEFAULT_PORT);
|
||||
|
||||
if (addr.addr.length <= 0) {
|
||||
throw new Error("Cannot be empty");
|
||||
}
|
||||
|
||||
if (addr.addr.length > address.MAX_ADDR_LEN) {
|
||||
throw new Error(
|
||||
"Can no longer than " + address.MAX_ADDR_LEN + " bytes",
|
||||
);
|
||||
}
|
||||
|
||||
if (addr.port <= 0) {
|
||||
throw new Error("Port must be specified");
|
||||
}
|
||||
|
||||
return "Look like " + addr.type + " address";
|
||||
},
|
||||
},
|
||||
Encoding: {
|
||||
name: "Encoding",
|
||||
description: "The character encoding of the server",
|
||||
type: "select",
|
||||
value: "utf-8",
|
||||
example: common.charsetPresets.join(","),
|
||||
readonly: false,
|
||||
suggestions(input) {
|
||||
return [];
|
||||
},
|
||||
verify(d) {
|
||||
for (let i in common.charsetPresets) {
|
||||
if (common.charsetPresets[i] !== d) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
throw new Error('The character encoding "' + d + '" is not supported');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class Wizard {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {command.Info} info
|
||||
* @param {presets.Preset} preset
|
||||
* @param {object} session
|
||||
* @param {Array<string>} keptSessions
|
||||
* @param {streams.Streams} streams
|
||||
* @param {subscribe.Subscribe} subs
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
*
|
||||
*/
|
||||
constructor(
|
||||
info,
|
||||
preset,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
) {
|
||||
this.info = info;
|
||||
this.preset = preset;
|
||||
this.hasStarted = false;
|
||||
this.streams = streams;
|
||||
this.session = session;
|
||||
this.keptSessions = keptSessions;
|
||||
this.step = subs;
|
||||
this.controls = controls.get("Telnet");
|
||||
this.history = history;
|
||||
}
|
||||
|
||||
run() {
|
||||
this.step.resolve(this.stepInitialPrompt());
|
||||
}
|
||||
|
||||
started() {
|
||||
return this.hasStarted;
|
||||
}
|
||||
|
||||
control() {
|
||||
return this.controls;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.step.resolve(
|
||||
this.stepErrorDone(
|
||||
"Action cancelled",
|
||||
"Action has been cancelled without reach any success",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stepErrorDone(title, message) {
|
||||
return command.done(false, null, title, message);
|
||||
}
|
||||
|
||||
stepSuccessfulDone(data) {
|
||||
return command.done(
|
||||
true,
|
||||
data,
|
||||
"Success!",
|
||||
"We have connected to the remote",
|
||||
);
|
||||
}
|
||||
|
||||
stepWaitForAcceptWait() {
|
||||
return command.wait(
|
||||
"Requesting",
|
||||
"Waiting for the request to be accepted by the backend",
|
||||
);
|
||||
}
|
||||
|
||||
stepWaitForEstablishWait(host) {
|
||||
return command.wait(
|
||||
"Connecting to " + host,
|
||||
"Establishing connection with the remote host, may take a while",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {stream.Sender} sender
|
||||
* @param {object} configInput
|
||||
* @param {object} sessionData
|
||||
*
|
||||
*/
|
||||
buildCommand(sender, configInput, sessionData) {
|
||||
let self = this;
|
||||
|
||||
let parsedConfig = {
|
||||
host: address.parseHostPort(configInput.host, DEFAULT_PORT),
|
||||
charset: configInput.charset,
|
||||
};
|
||||
|
||||
// Copy the keptSessions from the record so it will not be overwritten here
|
||||
let keptSessions = self.keptSessions ? [].concat(...self.keptSessions) : [];
|
||||
|
||||
return new Telnet(sender, parsedConfig, {
|
||||
"initialization.failed"(streamInitialHeader) {
|
||||
switch (streamInitialHeader.data()) {
|
||||
case SERVER_INITIAL_ERROR_BAD_ADDRESS:
|
||||
self.step.resolve(
|
||||
self.stepErrorDone("Request rejected", "Invalid address"),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self.step.resolve(
|
||||
self.stepErrorDone(
|
||||
"Request rejected",
|
||||
"Unknown error code: " + streamInitialHeader.data(),
|
||||
),
|
||||
);
|
||||
},
|
||||
initialized(streamInitialHeader) {
|
||||
self.step.resolve(self.stepWaitForEstablishWait(configInput.host));
|
||||
},
|
||||
"connect.succeed"(rd, commandHandler) {
|
||||
self.step.resolve(
|
||||
self.stepSuccessfulDone(
|
||||
new command.Result(
|
||||
configInput.host,
|
||||
self.info,
|
||||
self.controls.build({
|
||||
charset: parsedConfig.charset,
|
||||
send(data) {
|
||||
return commandHandler.sendData(data);
|
||||
},
|
||||
close() {
|
||||
return commandHandler.sendClose();
|
||||
},
|
||||
events: commandHandler.events,
|
||||
}),
|
||||
self.controls.ui(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
self.history.save(
|
||||
self.info.name() + ":" + configInput.host,
|
||||
configInput.host,
|
||||
new Date(),
|
||||
self.info,
|
||||
configInput,
|
||||
sessionData,
|
||||
keptSessions,
|
||||
);
|
||||
},
|
||||
async "connect.failed"(rd) {
|
||||
let readed = await reader.readCompletely(rd),
|
||||
message = new TextDecoder("utf-8").decode(readed.buffer);
|
||||
|
||||
self.step.resolve(self.stepErrorDone("Connection failed", message));
|
||||
},
|
||||
"@inband"(rd) {},
|
||||
close() {},
|
||||
"@completed"() {},
|
||||
});
|
||||
}
|
||||
|
||||
stepInitialPrompt() {
|
||||
const self = this;
|
||||
|
||||
return command.prompt(
|
||||
"Telnet",
|
||||
"Teletype Network",
|
||||
"Connect",
|
||||
(r) => {
|
||||
self.hasStarted = true;
|
||||
|
||||
self.streams.request(COMMAND_ID, (sd) => {
|
||||
return self.buildCommand(
|
||||
sd,
|
||||
{
|
||||
host: r.host,
|
||||
charset: r.encoding,
|
||||
},
|
||||
self.session,
|
||||
);
|
||||
});
|
||||
|
||||
self.step.resolve(self.stepWaitForAcceptWait());
|
||||
},
|
||||
() => {},
|
||||
command.fieldsWithPreset(
|
||||
initialFieldDef,
|
||||
[
|
||||
{
|
||||
name: "Host",
|
||||
suggestions(input) {
|
||||
const hosts = self.history.search(
|
||||
"Telnet",
|
||||
"host",
|
||||
input,
|
||||
HostMaxSearchResults,
|
||||
);
|
||||
|
||||
let sugg = [];
|
||||
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
sugg.push({
|
||||
title: hosts[i].title,
|
||||
value: hosts[i].data.host,
|
||||
meta: {
|
||||
Encoding: hosts[i].data.charset,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return sugg;
|
||||
},
|
||||
},
|
||||
{ name: "Encoding" },
|
||||
],
|
||||
self.preset,
|
||||
(r) => {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Executor extends Wizard {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {command.Info} info
|
||||
* @param {object} config
|
||||
* @param {object} session
|
||||
* @param {Array<string>} keptSessions
|
||||
* @param {streams.Streams} streams
|
||||
* @param {subscribe.Subscribe} subs
|
||||
* @param {controls.Controls} controls
|
||||
* @param {history.History} history
|
||||
*
|
||||
*/
|
||||
constructor(
|
||||
info,
|
||||
config,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
) {
|
||||
super(
|
||||
info,
|
||||
presets.emptyPreset(),
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
);
|
||||
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
stepInitialPrompt() {
|
||||
const self = this;
|
||||
|
||||
self.hasStarted = true;
|
||||
|
||||
self.streams.request(COMMAND_ID, (sd) => {
|
||||
return self.buildCommand(
|
||||
sd,
|
||||
{
|
||||
host: self.config.host,
|
||||
charset: self.config.charset ? self.config.charset : "utf-8",
|
||||
},
|
||||
self.session,
|
||||
);
|
||||
});
|
||||
|
||||
return self.stepWaitForAcceptWait();
|
||||
}
|
||||
}
|
||||
|
||||
export class Command {
|
||||
constructor() {}
|
||||
|
||||
id() {
|
||||
return COMMAND_ID;
|
||||
}
|
||||
|
||||
name() {
|
||||
return "Telnet";
|
||||
}
|
||||
|
||||
description() {
|
||||
return "Teletype Network";
|
||||
}
|
||||
|
||||
color() {
|
||||
return "#6ac";
|
||||
}
|
||||
|
||||
wizard(
|
||||
info,
|
||||
preset,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
) {
|
||||
return new Wizard(
|
||||
info,
|
||||
preset,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
);
|
||||
}
|
||||
|
||||
execute(
|
||||
info,
|
||||
config,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
) {
|
||||
return new Executor(
|
||||
info,
|
||||
config,
|
||||
session,
|
||||
keptSessions,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
);
|
||||
}
|
||||
|
||||
launch(info, launcher, streams, subs, controls, history) {
|
||||
const d = launcher.split("|", 2);
|
||||
|
||||
if (d.length <= 0) {
|
||||
throw new Exception('Given launcher "' + launcher + '" was invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
initialFieldDef["Host"].verify(d[0]);
|
||||
} catch (e) {
|
||||
throw new Exception(
|
||||
'Given launcher "' + launcher + '" was invalid: ' + e,
|
||||
);
|
||||
}
|
||||
|
||||
let charset = "utf-8";
|
||||
|
||||
if (d.length > 1) {
|
||||
// TODO: Remove this check after depreciation period.
|
||||
try {
|
||||
initialFieldDef["Encoding"].verify(d[1]);
|
||||
|
||||
charset = d[1];
|
||||
} catch (e) {
|
||||
throw new Exception(
|
||||
'Given launcher "' + launcher + '" was invalid: ' + e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.execute(
|
||||
info,
|
||||
{
|
||||
host: d[0],
|
||||
charset: charset,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
streams,
|
||||
subs,
|
||||
controls,
|
||||
history,
|
||||
);
|
||||
}
|
||||
|
||||
launcher(config) {
|
||||
return config.host + "|" + (config.charset ? config.charset : "utf-8");
|
||||
}
|
||||
|
||||
represet(preset) {
|
||||
const host = preset.host();
|
||||
|
||||
if (host.length > 0) {
|
||||
preset.insertMeta("Host", host);
|
||||
}
|
||||
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
694
ui/common.css
Normal file
694
ui/common.css
Normal file
@ -0,0 +1,694 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "UTF-8";
|
||||
|
||||
@import "~normalize.css";
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #444;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tab1 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-items: center;
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab1 > li {
|
||||
padding: 0 15px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
word-wrap: none;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex: initial;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab1 > li.active {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.tab1.tab1-list {
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab1.tab1-list > li {
|
||||
flex: 0 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab2 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-items: center;
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
color: #aaa;
|
||||
border-bottom: 1px solid #a56;
|
||||
background: #333;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab2::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -3px 3px #0003;
|
||||
}
|
||||
|
||||
.tab2 > li {
|
||||
flex: auto;
|
||||
cursor: pointer;
|
||||
border-color: transparent;
|
||||
border-width: 1px 1px 0 1px;
|
||||
border-style: solid;
|
||||
padding: 7px 10px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tab2 > li.active {
|
||||
color: #fff;
|
||||
background: #644;
|
||||
margin-bottom: -1px;
|
||||
border-color: #a56;
|
||||
border-style: solid;
|
||||
box-shadow: 0 -2px 2px #0002;
|
||||
}
|
||||
|
||||
/* List */
|
||||
.lst-nostyle {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.hlst {
|
||||
}
|
||||
|
||||
.hlst > li {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.lst1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lst1 > li {
|
||||
border-bottom: 1px solid #555;
|
||||
}
|
||||
|
||||
.lst1 > li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lst1 > li .lst-wrap {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 > li {
|
||||
width: 33%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 > li .lst-wrap {
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
background: #333;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
box-shadow: 2px 2px 0 0 #222;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 > li .lst-wrap:hover {
|
||||
background: #3a3a3a;
|
||||
box-shadow: 2px 2px 0 0 #222;
|
||||
}
|
||||
|
||||
.hlst.lstcl1 > li .lst-wrap:active {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.hlst.lstcl2 {
|
||||
list-style: none;
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hlst.lstcl2 > li {
|
||||
width: 33%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hlst.lstcl2 > li .lst-wrap {
|
||||
padding: 10px;
|
||||
margin: 5px;
|
||||
background: #333;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.icon {
|
||||
line-height: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.icon.icon-close1 {
|
||||
margin-top: -2.5px;
|
||||
font-size: 26px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.icon.icon-close1::before {
|
||||
content: "\00d7";
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.icon.icon-plus1 {
|
||||
color: #fff;
|
||||
background: #a56;
|
||||
}
|
||||
|
||||
.icon.icon-plus1::before {
|
||||
content: "+";
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon.icon-more1 {
|
||||
color: #fff;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.icon.icon-more1::before {
|
||||
content: "\2261";
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon.icon-warning1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon.icon-warning1::after {
|
||||
content: "!";
|
||||
font-weight: bold;
|
||||
background: #e11;
|
||||
padding: 3px 13px;
|
||||
}
|
||||
|
||||
.icon.icon-point1 {
|
||||
position: relative;
|
||||
text-shadow: 0 0 3px #fff;
|
||||
}
|
||||
|
||||
.icon.icon-point1::after {
|
||||
content: "\25CF";
|
||||
}
|
||||
|
||||
.icon.icon-keyboardkey1 {
|
||||
background: #fff;
|
||||
color: #999;
|
||||
padding: 4px 6px;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
box-shadow: 1px 1px 0 2px #0003;
|
||||
}
|
||||
|
||||
.icon.icon-iconed-bottom1 {
|
||||
padding: 4px 6px;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon.icon-iconed-bottom1 > i {
|
||||
font-style: normal;
|
||||
display: block;
|
||||
margin: 3px;
|
||||
font-size: 2em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Windows */
|
||||
.window {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.window.window1 {
|
||||
background: #a56;
|
||||
box-shadow: 0 0 5px #0006;
|
||||
color: #fff;
|
||||
font-size: 1em;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.window.window1.display {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.window.window1::before {
|
||||
top: -5px;
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: " ";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #a56;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.window.window1 .window-frame {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.window.window1 .window-title {
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: #e9a;
|
||||
}
|
||||
|
||||
.window.window1 .window-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
border-color: #844;
|
||||
cursor: pointer;
|
||||
}
|
||||
.window.window1 .window-close::after {
|
||||
border-color: #844;
|
||||
}
|
||||
|
||||
/* Form1 */
|
||||
.form1 {
|
||||
}
|
||||
|
||||
.form1 > fieldset,
|
||||
.form1 > fieldset * {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
border: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field {
|
||||
color: #ccc;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.horizontal {
|
||||
width: auto;
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .items {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.horizontal.item {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.horizontal:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field,
|
||||
.form1 > fieldset .field input,
|
||||
.form1 > fieldset .field select,
|
||||
.form1 > fieldset .field textarea,
|
||||
.form1 > fieldset .field button {
|
||||
vertical-align: middle;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field {
|
||||
font-size: 0.95em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .textinfo {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .textinfo > .info {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
font-size: 1.1em;
|
||||
background: #292929;
|
||||
border: 1px solid #444;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input,
|
||||
.form1 > fieldset .field > select,
|
||||
.form1 > fieldset .field > textarea,
|
||||
.form1 > fieldset .field > button {
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input:focus::placeholder {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="text"],
|
||||
.form1 > fieldset .field > input[type="file"],
|
||||
.form1 > fieldset .field > input[type="email"],
|
||||
.form1 > fieldset .field > input[type="number"],
|
||||
.form1 > fieldset .field > input[type="search"],
|
||||
.form1 > fieldset .field > input[type="tel"],
|
||||
.form1 > fieldset .field > input[type="url"],
|
||||
.form1 > fieldset .field > input[type="password"],
|
||||
.form1 > fieldset .field > select,
|
||||
.form1 > fieldset .field > textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
background: #2e2e2e;
|
||||
margin-top: 5px;
|
||||
border-bottom: 2px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > textarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.error > .error {
|
||||
margin-top: 5px;
|
||||
color: #f55;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .message {
|
||||
margin-top: 5px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .message * {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .message > p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > .message > a {
|
||||
color: #e9a;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.highlight > input[type="text"],
|
||||
.form1 > fieldset .field.highlight > input[type="file"],
|
||||
.form1 > fieldset .field.highlight > input[type="email"],
|
||||
.form1 > fieldset .field.highlight > input[type="number"],
|
||||
.form1 > fieldset .field.highlight > input[type="search"],
|
||||
.form1 > fieldset .field.highlight > input[type="tel"],
|
||||
.form1 > fieldset .field.highlight > input[type="url"],
|
||||
.form1 > fieldset .field.highlight > input[type="password"],
|
||||
.form1 > fieldset .field.highlight > select,
|
||||
.form1 > fieldset .field.highlight > textarea {
|
||||
background: #666;
|
||||
border-bottom: 2px solid #ccc;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field.error > input[type="text"],
|
||||
.form1 > fieldset .field.error > input[type="file"],
|
||||
.form1 > fieldset .field.error > input[type="email"],
|
||||
.form1 > fieldset .field.error > input[type="number"],
|
||||
.form1 > fieldset .field.error > input[type="search"],
|
||||
.form1 > fieldset .field.error > input[type="tel"],
|
||||
.form1 > fieldset .field.error > input[type="url"],
|
||||
.form1 > fieldset .field.error > input[type="password"],
|
||||
.form1 > fieldset .field.error > select,
|
||||
.form1 > fieldset .field.error > textarea {
|
||||
background: #483535;
|
||||
border-bottom: 2px solid #a83333;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input:disabled,
|
||||
.form1 > fieldset .field > select:disabled,
|
||||
.form1 > fieldset .field > textarea:disabled,
|
||||
.form1 > fieldset .field > button:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input:disabled:active,
|
||||
.form1 > fieldset .field > select:disabled:active,
|
||||
.form1 > fieldset .field > textarea:disabled:active,
|
||||
.form1 > fieldset .field > button:disabled:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="checkbox"],
|
||||
.form1 > fieldset .field > input[type="radio"] {
|
||||
background: #2e2e2e;
|
||||
margin: 1px 3px 1px 1px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="checkbox"]:active,
|
||||
.form1 > fieldset .field > input[type="radio"]:active,
|
||||
.form1 > fieldset .field > input[type="checkbox"]:focus,
|
||||
.form1 > fieldset .field > input[type="radio"]:focus {
|
||||
outline: 1px solid #e9a;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > input[type="text"]:focus,
|
||||
.form1 > fieldset .field > input[type="email"]:focus,
|
||||
.form1 > fieldset .field > input[type="number"]:focus,
|
||||
.form1 > fieldset .field > input[type="search"]:focus,
|
||||
.form1 > fieldset .field > input[type="tel"]:focus,
|
||||
.form1 > fieldset .field > input[type="url"]:focus,
|
||||
.form1 > fieldset .field > input[type="password"]:focus,
|
||||
.form1 > fieldset .field > select:focus,
|
||||
.form1 > fieldset .field > textarea:focus {
|
||||
background: #222;
|
||||
border-bottom: 2px solid #e9a;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button {
|
||||
padding: 8px 13px;
|
||||
font-weight: normal;
|
||||
background: #c78;
|
||||
border-color: #c78;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button:focus,
|
||||
.form1 > fieldset .field > button:hover {
|
||||
border-color: #a56;
|
||||
background: #c78;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button:active {
|
||||
background: #a56;
|
||||
border-color: #a56;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary {
|
||||
float: right;
|
||||
background: transparent;
|
||||
color: #eee;
|
||||
border-color: #eee;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary:focus,
|
||||
.form1 > fieldset .field > button.secondary:hover {
|
||||
border-color: #ddd;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > button.secondary:active {
|
||||
border-color: #666;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions {
|
||||
background: #262626;
|
||||
box-shadow: 0 0 3px #0006;
|
||||
border: 1px solid #666;
|
||||
position: relative;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions::before,
|
||||
.form1 > fieldset .field > ul.input-suggestions::after {
|
||||
top: -5px;
|
||||
left: 5px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
content: " ";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #262626;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions::after {
|
||||
top: -6px;
|
||||
z-index: 0;
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li:hover,
|
||||
.form1 > fieldset .field > ul.input-suggestions > li.current {
|
||||
background: #555;
|
||||
border-bottom: 1px solid #555;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li:first-child:hover::before,
|
||||
.form1
|
||||
> fieldset
|
||||
.field
|
||||
> ul.input-suggestions
|
||||
> li.current:first-child::before {
|
||||
top: -6px;
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
left: 5px;
|
||||
display: block;
|
||||
content: " ";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #555;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li > .sugt-title {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.form1 > fieldset .field > ul.input-suggestions > li > .sugt-value {
|
||||
color: #fdd;
|
||||
font-size: 0.9em;
|
||||
margin: 0 5px;
|
||||
}
|
||||
152
ui/control/ssh.js
Normal file
152
ui/control/ssh.js
Normal file
@ -0,0 +1,152 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as iconv from "iconv-lite";
|
||||
import * as color from "../commands/color.js";
|
||||
import * as common from "../commands/common.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as subscribe from "../stream/subscribe.js";
|
||||
|
||||
class Control {
|
||||
constructor(data, color) {
|
||||
this.colorM = color;
|
||||
this.colors = this.colorM.get();
|
||||
this.charset = data.charset;
|
||||
|
||||
this.charsetDecoder = (d) => {
|
||||
return iconv.decode(d, this.charset);
|
||||
};
|
||||
this.charsetEncoder = (dStr) => {
|
||||
return iconv.encode(dStr, this.charset);
|
||||
};
|
||||
|
||||
this.enable = false;
|
||||
this.sender = data.send;
|
||||
this.closer = data.close;
|
||||
this.resizer = data.resize;
|
||||
this.subs = new subscribe.Subscribe();
|
||||
|
||||
let self = this;
|
||||
|
||||
data.events.place("stdout", async (rd) => {
|
||||
try {
|
||||
self.subs.resolve(self.charsetDecoder(await reader.readCompletely(rd)));
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
});
|
||||
|
||||
data.events.place("stderr", async (rd) => {
|
||||
try {
|
||||
self.subs.resolve(self.charsetDecoder(await reader.readCompletely(rd)));
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
});
|
||||
|
||||
data.events.place("completed", () => {
|
||||
self.closed = true;
|
||||
self.colorM.forget(self.colors.color);
|
||||
|
||||
self.subs.reject("Remote connection has been terminated");
|
||||
});
|
||||
}
|
||||
|
||||
echo() {
|
||||
return false;
|
||||
}
|
||||
|
||||
resize(dim) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizer(dim.rows, dim.cols);
|
||||
}
|
||||
|
||||
enabled() {
|
||||
this.enable = true;
|
||||
}
|
||||
|
||||
disabled() {
|
||||
this.enable = false;
|
||||
}
|
||||
|
||||
retap(isOn) {}
|
||||
|
||||
receive() {
|
||||
return this.subs.subscribe();
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sender(this.charsetEncoder(data));
|
||||
}
|
||||
|
||||
sendBinary(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sender(common.strToBinary(data));
|
||||
}
|
||||
|
||||
color() {
|
||||
return this.colors.dark;
|
||||
}
|
||||
|
||||
activeColor() {
|
||||
return this.colors.color;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cc = this.closer;
|
||||
this.closer = null;
|
||||
|
||||
return cc();
|
||||
}
|
||||
}
|
||||
|
||||
export class SSH {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {color.Color} c
|
||||
*/
|
||||
constructor(c) {
|
||||
this.color = c;
|
||||
}
|
||||
|
||||
type() {
|
||||
return "SSH";
|
||||
}
|
||||
|
||||
ui() {
|
||||
return "Console";
|
||||
}
|
||||
|
||||
build(data) {
|
||||
return new Control(data, this.color);
|
||||
}
|
||||
}
|
||||
513
ui/control/telnet.js
Normal file
513
ui/control/telnet.js
Normal file
@ -0,0 +1,513 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as iconv from "iconv-lite";
|
||||
import * as color from "../commands/color.js";
|
||||
import * as common from "../commands/common.js";
|
||||
import Exception from "../commands/exception.js";
|
||||
import * as reader from "../stream/reader.js";
|
||||
import * as subscribe from "../stream/subscribe.js";
|
||||
|
||||
// const maxReadBufSize = 1024;
|
||||
|
||||
const cmdSE = 240;
|
||||
// const cmdNOP = 241;
|
||||
// const cmdDataMark = 242;
|
||||
// const cmdBreak = 243;
|
||||
// const cmdInterrputProcess = 244;
|
||||
// const cmdAbortOutput = 245;
|
||||
// const cmdAreYouThere = 246;
|
||||
// const cmdEraseCharacter = 247;
|
||||
// const cmdEraseLine = 248;
|
||||
const cmdGoAhead = 249;
|
||||
const cmdSB = 250;
|
||||
const cmdWill = 251;
|
||||
const cmdWont = 252;
|
||||
const cmdDo = 253;
|
||||
const cmdDont = 254;
|
||||
const cmdIAC = 255;
|
||||
|
||||
const optEcho = 1;
|
||||
const optSuppressGoAhead = 3;
|
||||
const optTerminalType = 24;
|
||||
const optNAWS = 31;
|
||||
|
||||
const optTerminalTypeIs = 0;
|
||||
const optTerminalTypeSend = 1;
|
||||
|
||||
const unknownTermTypeSendData = new Uint8Array([
|
||||
optTerminalTypeIs,
|
||||
88,
|
||||
84,
|
||||
69,
|
||||
82,
|
||||
77,
|
||||
]);
|
||||
|
||||
// Most of code of this class is directly from
|
||||
// https://github.com/ziutek/telnet/blob/master/conn.go#L122
|
||||
// Thank you!
|
||||
class Parser {
|
||||
constructor(sender, flusher, callbacks) {
|
||||
this.sender = sender;
|
||||
this.flusher = flusher;
|
||||
this.callbacks = callbacks;
|
||||
this.reader = new reader.Multiple(() => {});
|
||||
this.options = {
|
||||
echoEnabled: false,
|
||||
suppressGoAhead: false,
|
||||
nawsAccpeted: false,
|
||||
};
|
||||
this.current = 0;
|
||||
}
|
||||
|
||||
sendNego(cmd, option) {
|
||||
return this.sender(new Uint8Array([cmdIAC, cmd, option]));
|
||||
}
|
||||
|
||||
sendDeny(cmd, o) {
|
||||
switch (cmd) {
|
||||
case cmdDo:
|
||||
return this.sendNego(cmdWont, o);
|
||||
|
||||
case (cmdWill, cmdWont):
|
||||
return this.sendNego(cmdDont, o);
|
||||
}
|
||||
}
|
||||
|
||||
sendWillSubNego(willCmd, data, option) {
|
||||
let b = new Uint8Array(6 + data.length + 2);
|
||||
|
||||
b.set([cmdIAC, willCmd, option, cmdIAC, cmdSB, option], 0);
|
||||
b.set(data, 6);
|
||||
b.set([cmdIAC, cmdSE], data.length + 6);
|
||||
|
||||
return this.sender(b);
|
||||
}
|
||||
|
||||
sendSubNego(data, option) {
|
||||
let b = new Uint8Array(3 + data.length + 2);
|
||||
|
||||
b.set([cmdIAC, cmdSB, option], 0);
|
||||
b.set(data, 3);
|
||||
b.set([cmdIAC, cmdSE], data.length + 3);
|
||||
|
||||
return this.sender(b);
|
||||
}
|
||||
|
||||
async handleTermTypeSubNego(rd) {
|
||||
let action = await reader.readOne(rd);
|
||||
|
||||
if (action[0] !== optTerminalTypeSend) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let self = this;
|
||||
|
||||
return () => {
|
||||
self.sendSubNego(unknownTermTypeSendData, optTerminalType);
|
||||
};
|
||||
}
|
||||
|
||||
async handleSubNego(rd) {
|
||||
let endExec = null;
|
||||
|
||||
for (;;) {
|
||||
let d = await reader.readOne(rd);
|
||||
|
||||
switch (d[0]) {
|
||||
case optTerminalType:
|
||||
endExec = await this.handleTermTypeSubNego(rd);
|
||||
continue;
|
||||
|
||||
case cmdIAC:
|
||||
break;
|
||||
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
let e = await reader.readOne(rd);
|
||||
|
||||
if (e[0] !== cmdSE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (endExec !== null) {
|
||||
endExec();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleOption(cmd, option, oldVal, newVal) {
|
||||
switch (cmd) {
|
||||
case cmdWill:
|
||||
if (!oldVal) {
|
||||
this.sendNego(cmdDo, option);
|
||||
}
|
||||
|
||||
newVal(true, cmdWill);
|
||||
return;
|
||||
|
||||
case cmdWont:
|
||||
if (oldVal) {
|
||||
this.sendNego(cmdDont, option);
|
||||
}
|
||||
|
||||
newVal(false, cmdWont);
|
||||
return;
|
||||
|
||||
case cmdDo:
|
||||
if (!oldVal) {
|
||||
this.sendNego(cmdWill, option);
|
||||
}
|
||||
|
||||
newVal(true, cmdDo);
|
||||
return;
|
||||
|
||||
case cmdDont:
|
||||
if (oldVal) {
|
||||
this.sendNego(cmdWont, option);
|
||||
}
|
||||
|
||||
newVal(false, cmdDont);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async handleCmd(rd) {
|
||||
let d = await reader.readOne(rd);
|
||||
|
||||
switch (d[0]) {
|
||||
case cmdWill:
|
||||
case cmdWont:
|
||||
case cmdDo:
|
||||
case cmdDont:
|
||||
break;
|
||||
|
||||
case cmdIAC:
|
||||
this.flusher(d);
|
||||
return;
|
||||
|
||||
case cmdGoAhead:
|
||||
return;
|
||||
|
||||
case cmdSB:
|
||||
await this.handleSubNego(rd);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new Exception("Unknown command");
|
||||
}
|
||||
|
||||
let o = await reader.readOne(rd);
|
||||
|
||||
switch (o[0]) {
|
||||
case optEcho:
|
||||
return this.handleOption(
|
||||
d[0],
|
||||
o[0],
|
||||
this.options.echoEnabled,
|
||||
(d, action) => {
|
||||
this.options.echoEnabled = d;
|
||||
|
||||
switch (action) {
|
||||
case cmdWill:
|
||||
case cmdDont:
|
||||
this.callbacks.setEcho(false);
|
||||
break;
|
||||
|
||||
case cmdWont:
|
||||
case cmdDo:
|
||||
this.callbacks.setEcho(true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
case optSuppressGoAhead:
|
||||
return this.handleOption(
|
||||
d[0],
|
||||
o[0],
|
||||
this.options.suppressGoAhead,
|
||||
(d, _action) => {
|
||||
this.options.suppressGoAhead = d;
|
||||
},
|
||||
);
|
||||
|
||||
case optNAWS:
|
||||
// Window resize allowed?
|
||||
if (d[0] !== cmdDo) {
|
||||
this.sendDeny(d[0], o[0]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let dim = this.callbacks.getWindowDim(),
|
||||
dimData = new DataView(new ArrayBuffer(4));
|
||||
|
||||
dimData.setUint16(0, dim.cols);
|
||||
dimData.setUint16(2, dim.rows);
|
||||
|
||||
let dimBytes = new Uint8Array(dimData.buffer);
|
||||
|
||||
if (this.options.nawsAccpeted) {
|
||||
this.sendSubNego(dimBytes, optNAWS);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.nawsAccpeted = true;
|
||||
this.sendWillSubNego(cmdWill, dimBytes, optNAWS);
|
||||
}
|
||||
return;
|
||||
|
||||
case optTerminalType:
|
||||
if (d[0] !== cmdDo) {
|
||||
this.sendDeny(d[0], o[0]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendNego(cmdWill, o[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendDeny(d[0], o[0]);
|
||||
}
|
||||
|
||||
requestWindowResize() {
|
||||
this.options.nawsAccpeted = true;
|
||||
|
||||
this.sendNego(cmdWill, optNAWS);
|
||||
}
|
||||
|
||||
async run() {
|
||||
try {
|
||||
for (;;) {
|
||||
let d = await reader.readUntil(this.reader, cmdIAC);
|
||||
|
||||
if (!d.found) {
|
||||
this.flusher(d.data);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (d.data.length > 1) {
|
||||
this.flusher(d.data.slice(0, d.data.length - 1));
|
||||
}
|
||||
|
||||
await this.handleCmd(this.reader);
|
||||
}
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
feed(rd, cb) {
|
||||
this.reader.feed(rd, cb);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
class Control {
|
||||
constructor(data, color) {
|
||||
this.colorM = color;
|
||||
this.colors = this.colorM.get();
|
||||
this.charset = data.charset;
|
||||
|
||||
this.charsetDecoder = (d) => {
|
||||
return iconv.decode(d, this.charset);
|
||||
};
|
||||
this.charsetEncoder = (dStr) => {
|
||||
return iconv.encode(dStr, this.charset);
|
||||
};
|
||||
|
||||
this.sender = data.send;
|
||||
this.closer = data.close;
|
||||
this.closed = false;
|
||||
this.localEchoEnabled = true;
|
||||
this.subs = new subscribe.Subscribe();
|
||||
this.enable = false;
|
||||
this.windowDim = {
|
||||
cols: 65535,
|
||||
rows: 65535,
|
||||
};
|
||||
|
||||
let self = this;
|
||||
|
||||
this.parser = new Parser(
|
||||
this.sender,
|
||||
(d) => {
|
||||
self.subs.resolve(this.charsetDecoder(d));
|
||||
},
|
||||
{
|
||||
setEcho(newVal) {
|
||||
self.localEchoEnabled = newVal;
|
||||
},
|
||||
getWindowDim() {
|
||||
return self.windowDim;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let runWait = this.parser.run();
|
||||
|
||||
data.events.place("inband", (rd) => {
|
||||
return new Promise((resolve, _reject) => {
|
||||
self.parser.feed(rd, () => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
data.events.place("completed", async () => {
|
||||
self.parser.close();
|
||||
self.closed = true;
|
||||
|
||||
self.colorM.forget(self.colors.color);
|
||||
|
||||
await runWait;
|
||||
|
||||
self.subs.reject("Remote connection has been terminated");
|
||||
});
|
||||
}
|
||||
|
||||
echo() {
|
||||
return this.localEchoEnabled;
|
||||
}
|
||||
|
||||
resize(dim) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.windowDim.cols = dim.cols;
|
||||
this.windowDim.rows = dim.rows;
|
||||
|
||||
this.parser.requestWindowResize();
|
||||
}
|
||||
|
||||
enabled() {
|
||||
this.enable = true;
|
||||
}
|
||||
|
||||
disabled() {
|
||||
this.enable = false;
|
||||
}
|
||||
|
||||
retap(_isOn) {}
|
||||
|
||||
receive() {
|
||||
return this.subs.subscribe();
|
||||
}
|
||||
|
||||
searchNextIAC(start, data) {
|
||||
for (let i = start; i < data.length; i++) {
|
||||
if (data[i] !== cmdIAC) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
sendSeg(enc) {
|
||||
let currentLen = 0;
|
||||
|
||||
while (currentLen < enc.length) {
|
||||
const iacPos = this.searchNextIAC(currentLen, enc);
|
||||
|
||||
if (iacPos < 0) {
|
||||
this.sender(enc.slice(currentLen, enc.length));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.sender(enc.slice(currentLen, iacPos + 1));
|
||||
this.sender(enc.slice(iacPos, iacPos + 1));
|
||||
|
||||
currentLen = iacPos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendSeg(this.charsetEncoder(data));
|
||||
}
|
||||
|
||||
sendBinary(data) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.sendSeg(common.strToBinary(data));
|
||||
}
|
||||
|
||||
color() {
|
||||
return this.colors.dark;
|
||||
}
|
||||
|
||||
activeColor() {
|
||||
return this.colors.color;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cc = this.closer;
|
||||
this.closer = null;
|
||||
|
||||
return cc();
|
||||
}
|
||||
}
|
||||
|
||||
export class Telnet {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {color.Color} c
|
||||
*/
|
||||
constructor(c) {
|
||||
this.color = c;
|
||||
}
|
||||
|
||||
type() {
|
||||
return "Telnet";
|
||||
}
|
||||
|
||||
ui() {
|
||||
return "Console";
|
||||
}
|
||||
|
||||
build(data) {
|
||||
return new Control(data, this.color);
|
||||
}
|
||||
}
|
||||
118
ui/crypto.js
Normal file
118
ui/crypto.js
Normal file
@ -0,0 +1,118 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Generate HMAC 512 of given data
|
||||
*
|
||||
* @param {Uint8Array} secret Secret key
|
||||
* @param {Uint8Array} data Data to be HMAC'ed
|
||||
*/
|
||||
export async function hmac512(secret, data) {
|
||||
const key = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
secret,
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: { name: "SHA-512" },
|
||||
},
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
return window.crypto.subtle.sign(key.algorithm, key, data);
|
||||
}
|
||||
|
||||
export const GCMNonceSize = 12;
|
||||
export const GCMKeyBitLen = 128;
|
||||
|
||||
/**
|
||||
* Build AES GCM Encryption/Decryption key
|
||||
*
|
||||
* @param {Uint8Array} keyData Key data
|
||||
*/
|
||||
export function buildGCMKey(keyData) {
|
||||
return window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: GCMKeyBitLen,
|
||||
},
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data
|
||||
*
|
||||
* @param {CryptoKey} key Key
|
||||
* @param {Uint8Array} iv Nonce
|
||||
* @param {Uint8Array} plaintext Data to be encrypted
|
||||
*/
|
||||
export function encryptGCM(key, iv, plaintext) {
|
||||
return window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: iv, tagLength: GCMKeyBitLen },
|
||||
key,
|
||||
plaintext,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data
|
||||
*
|
||||
* @param {CryptoKey} key Key
|
||||
* @param {Uint8Array} iv Nonce
|
||||
* @param {Uint8Array} cipherText Data to be decrypted
|
||||
*/
|
||||
export function decryptGCM(key, iv, cipherText) {
|
||||
return window.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: iv, tagLength: GCMKeyBitLen },
|
||||
key,
|
||||
cipherText,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* generate Random nonce
|
||||
*
|
||||
*/
|
||||
export function generateNonce() {
|
||||
return window.crypto.getRandomValues(new Uint8Array(GCMNonceSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase nonce by one
|
||||
*
|
||||
* @param {Uint8Array} nonce Nonce data
|
||||
*
|
||||
* @returns {Uint8Array} New nonce
|
||||
*
|
||||
*/
|
||||
export function increaseNonce(nonce) {
|
||||
for (let i = nonce.length; i > 0; i--) {
|
||||
nonce[i - 1]++;
|
||||
|
||||
if (nonce[i - 1] <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return nonce;
|
||||
}
|
||||
37
ui/error.html
Normal file
37
ui/error.html
Normal file
@ -0,0 +1,37 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Error</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body class="app-error">
|
||||
<div id="app-loading">
|
||||
<div id="app-loading-frame">
|
||||
<div id="app-loading-error">×</div>
|
||||
|
||||
<h1 id="app-loading-title" class="error">
|
||||
Server was unable to complete the request
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
54
ui/history.js
Normal file
54
ui/history.js
Normal file
@ -0,0 +1,54 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
export class Records {
|
||||
/**
|
||||
* constructor
|
||||
*
|
||||
* @param {array} data Data space
|
||||
*/
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert new item into the history records
|
||||
*
|
||||
* @param {number} newData New value
|
||||
*/
|
||||
update(newData) {
|
||||
this.data.shift();
|
||||
this.data.push({ data: newData, class: "" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all existing data as expired
|
||||
*/
|
||||
expire() {
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
this.data[i].class = "expired";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return data
|
||||
*
|
||||
*/
|
||||
get() {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
422
ui/home.css
Normal file
422
ui/home.css
Normal file
@ -0,0 +1,422 @@
|
||||
/*
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@charset "utf-8";
|
||||
|
||||
@import "~roboto-fontface/css/roboto/roboto-fontface.css";
|
||||
|
||||
@keyframes home-window-display-flash {
|
||||
0% {
|
||||
top: -2px;
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 0 transparent;
|
||||
}
|
||||
|
||||
20% {
|
||||
height: 20px;
|
||||
box-shadow: 0 0 50px #fff;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
box-shadow: 0 0 10px #fff;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100%;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.home-window-display {
|
||||
}
|
||||
|
||||
.home-window-display::after {
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
animation-name: home-window-display-flash;
|
||||
animation-duration: 0.3s;
|
||||
animation-iteration-count: 1;
|
||||
box-shadow: 0 0 10px #fff;
|
||||
}
|
||||
|
||||
#home {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font: 1em "Roboto", sans-serif;
|
||||
}
|
||||
|
||||
#home-header {
|
||||
flex: 0 0 40px;
|
||||
font-size: 0.9em;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#home-hd-title {
|
||||
font-size: 1.1em;
|
||||
padding: 0 0 0 20px;
|
||||
font-weight: bold;
|
||||
flex: 0 0 65px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#home-hd-delay {
|
||||
font-size: 0.95em;
|
||||
display: flex;
|
||||
flex: 0 0 70px;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
margin: 0 10px;
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
justify-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#home-hd-title {
|
||||
padding: 0 0 0 10px;
|
||||
}
|
||||
|
||||
#home-hd-delay {
|
||||
flex: 0 0 60px;
|
||||
}
|
||||
}
|
||||
|
||||
#home-hd-delay-icon {
|
||||
color: #bbb;
|
||||
text-shadow: 0 0 3px #999;
|
||||
transition: linear 0.2s color, text-shadow;
|
||||
margin: 5px;
|
||||
font-size: 0.54em;
|
||||
}
|
||||
|
||||
#home-hd-delay-value {
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
word-wrap: none;
|
||||
}
|
||||
|
||||
@keyframes home-hd-delay-icon-flash {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
30% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
90% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes home-hd-delay-icon-working {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.green {
|
||||
color: #1e8;
|
||||
text-shadow: 0 0 3px #1e8;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.yellow {
|
||||
color: #ff4;
|
||||
text-shadow: 0 0 3px #ff4;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.orange {
|
||||
color: #f80;
|
||||
text-shadow: 0 0 3px #f80;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.red {
|
||||
color: #e11;
|
||||
text-shadow: 0 0 3px #e11;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.flash {
|
||||
animation-name: home-hd-delay-icon-flash;
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
#home-hd-delay-icon.working {
|
||||
animation-name: home-hd-delay-icon-working;
|
||||
animation-duration: 1.5s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
#home-hd-plus {
|
||||
flex: 0 0;
|
||||
padding: 0 13px;
|
||||
text-decoration: none;
|
||||
font-size: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes home-hd-plus-icon-flash {
|
||||
0% {
|
||||
background: #a56;
|
||||
}
|
||||
|
||||
20% {
|
||||
background: #5a7;
|
||||
}
|
||||
|
||||
40% {
|
||||
background: #96a;
|
||||
}
|
||||
|
||||
60% {
|
||||
background: #379;
|
||||
}
|
||||
|
||||
80% {
|
||||
background: #da0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background: #a56;
|
||||
}
|
||||
}
|
||||
|
||||
#home-hd-plus.working {
|
||||
color: #fff;
|
||||
background: #a56;
|
||||
animation-name: home-hd-plus-icon-flash;
|
||||
animation-duration: 10s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: normal;
|
||||
transition: linear 2s background;
|
||||
}
|
||||
|
||||
#home-hd-plus.working.intensify {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
|
||||
#home-hd-tabs {
|
||||
background: #333;
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs {
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li {
|
||||
flex: 0 0 180px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding: 0 15px;
|
||||
opacity: 0.5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li::after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
height: 0;
|
||||
transition: all 0.1s linear;
|
||||
transition-property: height, right, left;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.active::after {
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.updated::after {
|
||||
background: #fff3;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.error::after {
|
||||
background: #d55;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li > span.title {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li > span.title > span.type {
|
||||
display: inline-block;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
margin-right: 3px;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
background: #222;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li > .icon-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.active {
|
||||
color: #fff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.active > span.title {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
#home-hd-tabs-tabs > li.active > .icon-close {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
margin-top: -5px;
|
||||
color: #fff6;
|
||||
}
|
||||
|
||||
#home-hd-tabs-list {
|
||||
display: flex;
|
||||
font-size: 22px;
|
||||
flex: 0 0;
|
||||
padding: 0 13px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 0 3px #333;
|
||||
}
|
||||
|
||||
#home-content {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#home-content {
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
#home-content-wrap {
|
||||
max-width: 520px;
|
||||
margin: 50px auto;
|
||||
padding: 0 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#home-content h1 {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
#home-content p {
|
||||
margin: 10px 0;
|
||||
font-size: 0.9em;
|
||||
color: #eee;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#home-content p.secondary {
|
||||
margin: 5px 0;
|
||||
line-height: 1.5;
|
||||
font-size: 0.7em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#home-content p a {
|
||||
color: #e9a;
|
||||
}
|
||||
|
||||
#home-content hr {
|
||||
height: 2px;
|
||||
background: #3c3c3c;
|
||||
border: none;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
#home-content-connect {
|
||||
padding: 5px;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
616
ui/home.vue
Normal file
616
ui/home.vue
Normal file
@ -0,0 +1,616 @@
|
||||
<!--
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div id="home">
|
||||
<header id="home-header">
|
||||
<h1 id="home-hd-title">Sshwifty</h1>
|
||||
|
||||
<a id="home-hd-delay" href="javascript:;" @click="showDelayWindow">
|
||||
<span
|
||||
id="home-hd-delay-icon"
|
||||
class="icon icon-point1"
|
||||
:class="socket.classStyle"
|
||||
></span>
|
||||
<span v-if="socket.message.length > 0" id="home-hd-delay-value">{{
|
||||
socket.message
|
||||
}}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
id="home-hd-plus"
|
||||
class="icon icon-plus1"
|
||||
href="javascript:;"
|
||||
:class="{
|
||||
working: connector.inputting,
|
||||
intensify: connector.inputting && !windows.connect,
|
||||
}"
|
||||
@click="showConnectWindow"
|
||||
></a>
|
||||
|
||||
<tabs
|
||||
id="home-hd-tabs"
|
||||
:tab="tab.current"
|
||||
:tabs="tab.tabs"
|
||||
tabs-class="tab1"
|
||||
list-trigger-class="icon icon-more1"
|
||||
@current="switchTab"
|
||||
@retap="retapTab"
|
||||
@list="showTabsWindow"
|
||||
@close="closeTab"
|
||||
></tabs>
|
||||
</header>
|
||||
|
||||
<screens
|
||||
id="home-content"
|
||||
:screen="tab.current"
|
||||
:screens="tab.tabs"
|
||||
:view-port="viewPort"
|
||||
@stopped="tabStopped"
|
||||
@warning="tabWarning"
|
||||
@info="tabInfo"
|
||||
@updated="tabUpdated"
|
||||
>
|
||||
<div id="home-content-wrap">
|
||||
<h1>Bienvenue sur SSHWIFTY</h1>
|
||||
|
||||
<p>
|
||||
Un client web SSH open source permettant de se connecter à un serveur sans terminal.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Cliquer sur le
|
||||
<span
|
||||
id="home-content-connect"
|
||||
class="icon icon-plus1"
|
||||
@click="showConnectWindow"
|
||||
></span>
|
||||
en haut à gauche.
|
||||
</p>
|
||||
|
||||
<div v-if="serverMessage.length > 0">
|
||||
<hr />
|
||||
<p class="secondary" v-html="serverMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
</screens>
|
||||
|
||||
<connect-widget
|
||||
:inputting="connector.inputting"
|
||||
:display="windows.connect"
|
||||
:connectors="connector.connectors"
|
||||
:presets="presets"
|
||||
:restricted-to-presets="restrictedToPresets"
|
||||
:knowns="connector.knowns"
|
||||
:knowns-launcher-builder="buildknownLauncher"
|
||||
:knowns-export="exportKnowns"
|
||||
:knowns-import="importKnowns"
|
||||
:busy="connector.busy"
|
||||
@display="windows.connect = $event"
|
||||
@connector-select="connectNew"
|
||||
@known-select="connectKnown"
|
||||
@known-remove="removeKnown"
|
||||
@preset-select="connectPreset"
|
||||
@known-clear-session="clearSessionKnown"
|
||||
>
|
||||
<connector
|
||||
:connector="connector.connector"
|
||||
@cancel="cancelConnection"
|
||||
@done="connectionSucceed"
|
||||
>
|
||||
</connector>
|
||||
</connect-widget>
|
||||
<status-widget
|
||||
:class="socket.windowClass"
|
||||
:display="windows.delay"
|
||||
:status="socket.status"
|
||||
@display="windows.delay = $event"
|
||||
></status-widget>
|
||||
<tab-window
|
||||
:tab="tab.current"
|
||||
:tabs="tab.tabs"
|
||||
:display="windows.tabs"
|
||||
tabs-class="tab1 tab1-list"
|
||||
@display="windows.tabs = $event"
|
||||
@current="switchTab"
|
||||
@retap="retapTab"
|
||||
@close="closeTab"
|
||||
></tab-window>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "./home.css";
|
||||
|
||||
import ConnectWidget from "./widgets/connect.vue";
|
||||
import StatusWidget from "./widgets/status.vue";
|
||||
import Connector from "./widgets/connector.vue";
|
||||
import Tabs from "./widgets/tabs.vue";
|
||||
import TabWindow from "./widgets/tab_window.vue";
|
||||
import Screens from "./widgets/screens.vue";
|
||||
|
||||
import * as home_socket from "./home_socketctl.js";
|
||||
import * as home_history from "./home_historyctl.js";
|
||||
|
||||
import * as presets from "./commands/presets.js";
|
||||
|
||||
const BACKEND_CONNECT_ERROR =
|
||||
"Unable to connect to the Sshwifty backend server: ";
|
||||
const BACKEND_REQUEST_ERROR = "Unable to perform request: ";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
"connect-widget": ConnectWidget,
|
||||
"status-widget": StatusWidget,
|
||||
connector: Connector,
|
||||
tabs: Tabs,
|
||||
"tab-window": TabWindow,
|
||||
screens: Screens,
|
||||
},
|
||||
props: {
|
||||
hostPath: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
connection: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
controls: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
commands: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
serverMessage: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
presetData: {
|
||||
type: Object,
|
||||
default: () => new presets.Presets([]),
|
||||
},
|
||||
restrictedToPresets: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
viewPort: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
let history = home_history.build(this);
|
||||
|
||||
return {
|
||||
ticker: null,
|
||||
windows: {
|
||||
delay: false,
|
||||
connect: false,
|
||||
tabs: false,
|
||||
},
|
||||
socket: home_socket.build(this),
|
||||
connector: {
|
||||
historyRec: history,
|
||||
connector: null,
|
||||
connectors: this.commands.all(),
|
||||
inputting: false,
|
||||
acquired: false,
|
||||
busy: false,
|
||||
knowns: history.all(),
|
||||
},
|
||||
presets: this.commands.mergePresets(this.presetData),
|
||||
tab: {
|
||||
current: -1,
|
||||
lastID: 0,
|
||||
tabs: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.ticker = setInterval(() => {
|
||||
this.tick();
|
||||
}, 1000);
|
||||
|
||||
if (this.query.length > 1 && this.query.indexOf("+") === 0) {
|
||||
this.connectLaunch(this.query.slice(1, this.query.length), (success) => {
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("navigate-to", "");
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", this.onBrowserClose);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener("beforeunload", this.onBrowserClose);
|
||||
|
||||
if (this.ticker === null) {
|
||||
clearInterval(this.ticker);
|
||||
this.ticker = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onBrowserClose(e) {
|
||||
if (this.tab.current < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const msg = "Some tabs are still open, are you sure you want to exit?";
|
||||
(e || window.event).returnValue = msg;
|
||||
return msg;
|
||||
},
|
||||
tick() {
|
||||
let now = new Date();
|
||||
|
||||
this.socket.update(now, this);
|
||||
},
|
||||
closeAllWindow(e) {
|
||||
for (let i in this.windows) {
|
||||
this.windows[i] = false;
|
||||
}
|
||||
},
|
||||
showDelayWindow() {
|
||||
this.closeAllWindow();
|
||||
this.windows.delay = true;
|
||||
},
|
||||
showConnectWindow() {
|
||||
this.closeAllWindow();
|
||||
this.windows.connect = true;
|
||||
},
|
||||
showTabsWindow() {
|
||||
this.closeAllWindow();
|
||||
this.windows.tabs = true;
|
||||
},
|
||||
async getStreamThenRun(run, end) {
|
||||
let errStr = null;
|
||||
|
||||
try {
|
||||
let conn = await this.connection.get(this.socket);
|
||||
|
||||
try {
|
||||
run(conn);
|
||||
} catch (e) {
|
||||
errStr = BACKEND_REQUEST_ERROR + e;
|
||||
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
} catch (e) {
|
||||
errStr = BACKEND_CONNECT_ERROR + e;
|
||||
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
|
||||
end();
|
||||
|
||||
if (errStr !== null) {
|
||||
alert(errStr);
|
||||
}
|
||||
},
|
||||
runConnect(callback) {
|
||||
if (this.connector.acquired) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connector.acquired = true;
|
||||
this.connector.busy = true;
|
||||
|
||||
this.getStreamThenRun(
|
||||
(stream) => {
|
||||
this.connector.busy = false;
|
||||
|
||||
callback(stream);
|
||||
},
|
||||
() => {
|
||||
this.connector.busy = false;
|
||||
this.connector.acquired = false;
|
||||
},
|
||||
);
|
||||
},
|
||||
connectNew(connector) {
|
||||
const self = this;
|
||||
|
||||
self.runConnect((stream) => {
|
||||
self.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.wizard(
|
||||
stream,
|
||||
self.controls,
|
||||
self.connector.historyRec,
|
||||
presets.emptyPreset(),
|
||||
null,
|
||||
false,
|
||||
() => {},
|
||||
),
|
||||
};
|
||||
|
||||
self.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
connectPreset(preset) {
|
||||
const self = this;
|
||||
|
||||
self.runConnect((stream) => {
|
||||
self.connector.connector = {
|
||||
id: preset.command.id(),
|
||||
name: preset.command.name(),
|
||||
description: preset.command.description(),
|
||||
wizard: preset.command.wizard(
|
||||
stream,
|
||||
self.controls,
|
||||
self.connector.historyRec,
|
||||
preset.preset,
|
||||
null,
|
||||
[],
|
||||
() => {},
|
||||
),
|
||||
};
|
||||
|
||||
self.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
getConnectorByType(type) {
|
||||
let connector = null;
|
||||
|
||||
for (let c in this.connector.connectors) {
|
||||
if (this.connector.connectors[c].name() !== type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
connector = this.connector.connectors[c];
|
||||
}
|
||||
|
||||
return connector;
|
||||
},
|
||||
connectKnown(known) {
|
||||
const self = this;
|
||||
|
||||
self.runConnect((stream) => {
|
||||
let connector = self.getConnectorByType(known.type);
|
||||
|
||||
if (!connector) {
|
||||
alert("Unknown connector: " + known.type);
|
||||
|
||||
self.connector.inputting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.execute(
|
||||
stream,
|
||||
self.controls,
|
||||
self.connector.historyRec,
|
||||
known.data,
|
||||
known.session,
|
||||
known.keptSessions,
|
||||
() => {
|
||||
self.connector.knowns = self.connector.historyRec.all();
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
self.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
parseConnectLauncher(ll) {
|
||||
let llSeparatorIdx = ll.indexOf(":");
|
||||
|
||||
// Type must contain at least one charater
|
||||
if (llSeparatorIdx <= 0) {
|
||||
throw new Error("Invalid Launcher string");
|
||||
}
|
||||
|
||||
return {
|
||||
type: ll.slice(0, llSeparatorIdx),
|
||||
query: ll.slice(llSeparatorIdx + 1, ll.length),
|
||||
};
|
||||
},
|
||||
connectLaunch(launcher, done) {
|
||||
this.showConnectWindow();
|
||||
|
||||
this.runConnect((stream) => {
|
||||
let ll = this.parseConnectLauncher(launcher),
|
||||
connector = this.getConnectorByType(ll.type);
|
||||
|
||||
if (!connector) {
|
||||
alert("Unknown connector: " + ll.type);
|
||||
|
||||
this.connector.inputting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
|
||||
this.connector.connector = {
|
||||
id: connector.id(),
|
||||
name: connector.name(),
|
||||
description: connector.description(),
|
||||
wizard: connector.launch(
|
||||
stream,
|
||||
this.controls,
|
||||
this.connector.historyRec,
|
||||
ll.query,
|
||||
(n) => {
|
||||
self.connector.knowns = self.connector.historyRec.all();
|
||||
|
||||
done(n.data().success);
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
this.connector.inputting = true;
|
||||
});
|
||||
},
|
||||
buildknownLauncher(known) {
|
||||
let connector = this.getConnectorByType(known.type);
|
||||
|
||||
if (!connector) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.hostPath + "#+" + connector.launcher(known.data);
|
||||
},
|
||||
exportKnowns() {
|
||||
return this.connector.historyRec.export();
|
||||
},
|
||||
importKnowns(d) {
|
||||
this.connector.historyRec.import(d);
|
||||
|
||||
this.connector.knowns = this.connector.historyRec.all();
|
||||
},
|
||||
removeKnown(uid) {
|
||||
this.connector.historyRec.del(uid);
|
||||
|
||||
this.connector.knowns = this.connector.historyRec.all();
|
||||
},
|
||||
clearSessionKnown(uid) {
|
||||
this.connector.historyRec.clearSession(uid);
|
||||
|
||||
this.connector.knowns = this.connector.historyRec.all();
|
||||
},
|
||||
cancelConnection() {
|
||||
this.connector.inputting = false;
|
||||
this.connector.acquired = false;
|
||||
},
|
||||
connectionSucceed(data) {
|
||||
this.connector.inputting = false;
|
||||
this.connector.acquired = false;
|
||||
this.windows.connect = false;
|
||||
|
||||
this.addToTab(data);
|
||||
|
||||
this.$emit("tab-opened", this.tab.tabs);
|
||||
},
|
||||
async addToTab(data) {
|
||||
await this.switchTab(
|
||||
this.tab.tabs.push({
|
||||
id: this.tab.lastID++,
|
||||
name: data.name,
|
||||
info: data.info,
|
||||
control: data.control,
|
||||
ui: data.ui,
|
||||
toolbar: false,
|
||||
indicator: {
|
||||
level: "",
|
||||
message: "",
|
||||
updated: false,
|
||||
},
|
||||
status: {
|
||||
closing: false,
|
||||
},
|
||||
}) - 1,
|
||||
);
|
||||
},
|
||||
removeFromTab(index) {
|
||||
let isLast = index === this.tab.tabs.length - 1;
|
||||
|
||||
this.tab.tabs.splice(index, 1);
|
||||
this.tab.current = isLast ? this.tab.tabs.length - 1 : index;
|
||||
},
|
||||
async switchTab(to) {
|
||||
if (this.tab.current >= 0) {
|
||||
await this.tab.tabs[this.tab.current].control.disabled();
|
||||
}
|
||||
|
||||
this.tab.current = to;
|
||||
|
||||
this.tab.tabs[this.tab.current].indicator.updated = false;
|
||||
await this.tab.tabs[this.tab.current].control.enabled();
|
||||
},
|
||||
async retapTab(tab) {
|
||||
this.tab.tabs[tab].toolbar = !this.tab.tabs[tab].toolbar;
|
||||
|
||||
await this.tab.tabs[tab].control.retap(this.tab.tabs[tab].toolbar);
|
||||
},
|
||||
async closeTab(index) {
|
||||
if (this.tab.tabs[index].status.closing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tab.tabs[index].status.closing = true;
|
||||
|
||||
try {
|
||||
this.tab.tabs[index].control.disabled();
|
||||
|
||||
await this.tab.tabs[index].control.close();
|
||||
} catch (e) {
|
||||
alert("Cannot close tab due to error: " + e);
|
||||
|
||||
process.env.NODE_ENV === "development" && console.trace(e);
|
||||
}
|
||||
|
||||
this.removeFromTab(index);
|
||||
|
||||
this.$emit("tab-closed", this.tab.tabs);
|
||||
},
|
||||
tabStopped(index, reason) {
|
||||
if (reason !== null) {
|
||||
this.tab.tabs[index].indicator.message = "" + reason;
|
||||
this.tab.tabs[index].indicator.level = "error";
|
||||
} else {
|
||||
this.tab.tabs[index].indicator.message = "";
|
||||
this.tab.tabs[index].indicator.level = "";
|
||||
}
|
||||
},
|
||||
tabMessage(index, msg, type) {
|
||||
if (msg.toDismiss) {
|
||||
if (
|
||||
this.tab.tabs[index].indicator.message !== msg.text ||
|
||||
this.tab.tabs[index].indicator.level !== type
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tab.tabs[index].indicator.message = "";
|
||||
this.tab.tabs[index].indicator.level = "";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.tab.tabs[index].indicator.message = msg.text;
|
||||
this.tab.tabs[index].indicator.level = type;
|
||||
},
|
||||
tabWarning(index, msg) {
|
||||
this.tabMessage(index, msg, "warning");
|
||||
},
|
||||
tabInfo(index, msg) {
|
||||
this.tabMessage(index, msg, "info");
|
||||
},
|
||||
tabUpdated(index) {
|
||||
this.$emit("tab-updated", this.tab.tabs);
|
||||
|
||||
this.tab.tabs[index].indicator.updated = index !== this.tab.current;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
58
ui/home_historyctl.js
Normal file
58
ui/home_historyctl.js
Normal file
@ -0,0 +1,58 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import { History } from "./commands/history.js";
|
||||
|
||||
export function build(ctx) {
|
||||
let rec = [];
|
||||
|
||||
// This renames "knowns" to "sshwifty-knowns"
|
||||
// TODO: Remove this after some few years
|
||||
try {
|
||||
let oldStore = localStorage.getItem("knowns");
|
||||
|
||||
if (oldStore) {
|
||||
localStorage.setItem("sshwifty-knowns", oldStore);
|
||||
localStorage.removeItem("knowns");
|
||||
}
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
try {
|
||||
rec = JSON.parse(localStorage.getItem("sshwifty-knowns"));
|
||||
|
||||
if (!rec) {
|
||||
rec = [];
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Unable to load data of Known remotes: " + e);
|
||||
}
|
||||
|
||||
return new History(
|
||||
rec,
|
||||
(h, d) => {
|
||||
try {
|
||||
localStorage.setItem("sshwifty-knowns", JSON.stringify(d));
|
||||
ctx.connector.knowns = h.all();
|
||||
} catch (e) {
|
||||
alert("Unable to save remote history due to error: " + e);
|
||||
}
|
||||
},
|
||||
64,
|
||||
);
|
||||
}
|
||||
223
ui/home_socketctl.js
Normal file
223
ui/home_socketctl.js
Normal file
@ -0,0 +1,223 @@
|
||||
// Sshwifty - A Web SSH client
|
||||
//
|
||||
// Copyright (C) 2019-2023 Ni Rui <ranqus@gmail.com>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import * as history from "./history.js";
|
||||
import { ECHO_FAILED } from "./socket.js";
|
||||
|
||||
export function build(ctx) {
|
||||
const connectionStatusNotConnected = "Sshwifty is ready to connect";
|
||||
const connectionStatusConnecting =
|
||||
"Connecting to Sshwifty backend server. It should only take " +
|
||||
"less than a second, or two";
|
||||
const connectionStatusDisconnected =
|
||||
"Sshwifty is disconnected from it's backend server";
|
||||
const connectionStatusConnected =
|
||||
"Sshwifty is connected to it's backend server, user interface operational";
|
||||
const connectionStatusUnmeasurable =
|
||||
"Unable to measure connection delay. The connection maybe very " +
|
||||
"busy or already lost";
|
||||
|
||||
const connectionDelayGood =
|
||||
"Connection delay is low, operation should be very responsive";
|
||||
const connectionDelayFair =
|
||||
"Experiencing minor connection delay, operation should be responded " +
|
||||
"within a reasonable time";
|
||||
const connectionDelayMedian =
|
||||
"Experiencing median connection delay, consider to slow down your input " +
|
||||
"to avoid misoperation";
|
||||
const connectionDelayHeavy =
|
||||
"Experiencing bad connection delay, operation may freeze at any moment. " +
|
||||
"Consider to pause your input until remote is responsive";
|
||||
|
||||
const buildEmptyHistory = () => {
|
||||
let r = [];
|
||||
|
||||
for (let i = 0; i < 32; i++) {
|
||||
r.push({ data: 0, class: "" });
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
let isClosed = false,
|
||||
inboundPerSecond = 0,
|
||||
outboundPerSecond = 0,
|
||||
trafficPreSecondNextUpdate = new Date(),
|
||||
inboundPre10Seconds = 0,
|
||||
outboundPre10Seconds = 0,
|
||||
trafficPre10sNextUpdate = new Date(),
|
||||
inboundHistory = new history.Records(buildEmptyHistory()),
|
||||
outboundHistory = new history.Records(buildEmptyHistory()),
|
||||
trafficSamples = 0;
|
||||
|
||||
let delayHistory = new history.Records(buildEmptyHistory()),
|
||||
delaySamples = 0,
|
||||
delayPerInterval = 0;
|
||||
|
||||
return {
|
||||
update(time) {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (time >= trafficPreSecondNextUpdate) {
|
||||
trafficPreSecondNextUpdate = new Date(time.getTime() + 1000);
|
||||
inboundPre10Seconds += inboundPerSecond;
|
||||
outboundPre10Seconds += outboundPerSecond;
|
||||
|
||||
this.status.inbound = inboundPerSecond;
|
||||
this.status.outbound = outboundPerSecond;
|
||||
|
||||
inboundPerSecond = 0;
|
||||
outboundPerSecond = 0;
|
||||
|
||||
trafficSamples++;
|
||||
}
|
||||
|
||||
if (time >= trafficPre10sNextUpdate) {
|
||||
trafficPre10sNextUpdate = new Date(time.getTime() + 10000);
|
||||
|
||||
if (trafficSamples > 0) {
|
||||
inboundHistory.update(inboundPre10Seconds / trafficSamples);
|
||||
outboundHistory.update(outboundPre10Seconds / trafficSamples);
|
||||
|
||||
inboundPre10Seconds = 0;
|
||||
outboundPre10Seconds = 0;
|
||||
trafficSamples = 0;
|
||||
}
|
||||
|
||||
if (delaySamples > 0) {
|
||||
delayHistory.update(delayPerInterval / delaySamples);
|
||||
|
||||
delaySamples = 0;
|
||||
delayPerInterval = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
classStyle: "",
|
||||
windowClass: "",
|
||||
message: "",
|
||||
status: {
|
||||
description: connectionStatusNotConnected,
|
||||
delay: 0,
|
||||
delayHistory: delayHistory.get(),
|
||||
inbound: 0,
|
||||
inboundHistory: inboundHistory.get(),
|
||||
outbound: 0,
|
||||
outboundHistory: outboundHistory.get(),
|
||||
},
|
||||
connecting() {
|
||||
isClosed = false;
|
||||
|
||||
this.message = "--";
|
||||
this.classStyle = "working";
|
||||
this.windowClass = "";
|
||||
this.status.description = connectionStatusConnecting;
|
||||
},
|
||||
connected() {
|
||||
isClosed = false;
|
||||
|
||||
this.message = "??";
|
||||
this.classStyle = "working";
|
||||
this.windowClass = "";
|
||||
this.status.description = connectionStatusConnected;
|
||||
},
|
||||
traffic(inb, outb) {
|
||||
inboundPerSecond += inb;
|
||||
outboundPerSecond += outb;
|
||||
},
|
||||
echo(delay) {
|
||||
delayPerInterval += delay > 0 ? delay : 0;
|
||||
delaySamples++;
|
||||
|
||||
if (delay == ECHO_FAILED) {
|
||||
this.status.delay = -1;
|
||||
this.message = "";
|
||||
this.classStyle = "red flash";
|
||||
this.windowClass = "red";
|
||||
this.status.description = connectionStatusUnmeasurable;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let avgDelay = Math.round(delayPerInterval / delaySamples);
|
||||
|
||||
this.message = Number(avgDelay).toLocaleString() + "ms";
|
||||
this.status.delay = avgDelay;
|
||||
|
||||
if (avgDelay < 30) {
|
||||
this.classStyle = "green";
|
||||
this.windowClass = "green";
|
||||
this.status.description =
|
||||
connectionStatusConnected + ". " + connectionDelayGood;
|
||||
} else if (avgDelay < 100) {
|
||||
this.classStyle = "yellow";
|
||||
this.windowClass = "yellow";
|
||||
this.status.description =
|
||||
connectionStatusConnected + ". " + connectionDelayFair;
|
||||
} else if (avgDelay < 300) {
|
||||
this.classStyle = "orange";
|
||||
this.windowClass = "orange";
|
||||
this.status.description =
|
||||
connectionStatusConnected + ". " + connectionDelayMedian;
|
||||
} else {
|
||||
this.classStyle = "red";
|
||||
this.windowClass = "red";
|
||||
this.status.description =
|
||||
connectionStatusConnected + ". " + connectionDelayHeavy;
|
||||
}
|
||||
},
|
||||
close(e) {
|
||||
isClosed = true;
|
||||
delayHistory.expire();
|
||||
inboundHistory.expire();
|
||||
outboundHistory.expire();
|
||||
|
||||
ctx.connector.inputting = false;
|
||||
|
||||
if (e === null) {
|
||||
this.message = "";
|
||||
this.classStyle = "";
|
||||
this.status.description = connectionStatusDisconnected;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.delay = -1;
|
||||
this.message = "ERR";
|
||||
this.classStyle = "red flash";
|
||||
this.windowClass = "red";
|
||||
this.status.description = connectionStatusDisconnected + ": " + e;
|
||||
},
|
||||
failed(e) {
|
||||
isClosed = true;
|
||||
|
||||
ctx.connector.inputting = false;
|
||||
|
||||
if (e.code) {
|
||||
this.message = "E" + e.code;
|
||||
} else {
|
||||
this.message = "E????";
|
||||
}
|
||||
|
||||
this.status.delay = -1;
|
||||
this.classStyle = "red flash";
|
||||
this.windowClass = "red";
|
||||
this.status.description = connectionStatusDisconnected + ". Error: " + e;
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user