Commit ade3f7ee authored by ajlarrosa's avatar ajlarrosa

First Commit

parent 6c9a45d3
# on:
# push:
# tags:
# - 'v*'
on: [push]
name: Build&Release
jobs:
build:
name: Build release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: ^1.16
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: '14.x'
- name: Build static
run: cd web && yarn && yarn build && cd ../
- name: Get Go dependencies
run: go mod download && go get -u github.com/rakyll/statik
- name: Static->GO generation
run: statik --src=web/build
- name: Build
run: make
- uses: actions/upload-artifact@v2
with:
name: build-artifact
path: ssh-web-console-*
release:
name: On Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v2
with:
name: build-artifact
- run: ls -R
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
ssh-web-console-linux-amd64
ssh-web-console-linux-arm64
ssh-web-console-darwin-amd64
ssh-web-console-windows-amd64.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build/
statik/
.DS_Store
# Binaries for programs and plugins
*.exe
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
.glide/
*.exe~
static/
views/
.idea/
[submodule "web"]
path = web
url = https://github.com/genshen/webConsole.git
# build method: just run `docker build --rm --build-arg -t genshen/ssh-web-console .`
# build frontend code
FROM node:14.15.4-alpine3.12 AS frontend-builder
COPY web web-console/
RUN cd web-console \
&& yarn install \
&& yarn build
FROM golang:1.15.7-alpine3.13 AS builder
# set to 'on' if using go module
ARG STATIC_DIR=build
RUN apk add --no-cache git \
&& go get -u github.com/rakyll/statik
COPY ./ /go/src/github.com/genshen/ssh-web-console/
COPY --from=frontend-builder web-console/build /go/src/github.com/genshen/ssh-web-console/${STATIC_DIR}/
RUN cd ./src/github.com/genshen/ssh-web-console/ \
&& statik -src=${STATIC_DIR} \
&& go build \
&& go install
## copy binary
FROM alpine:3.13
ARG HOME="/home/web"
RUN adduser -D web -h ${HOME}
COPY --from=builder --chown=web /go/bin/ssh-web-console ${HOME}/ssh-web-console
WORKDIR ${HOME}
USER web
VOLUME ["${HOME}/conf"]
CMD ["./ssh-web-console"]
MIT License
Copyright (c) 2017-present genshen chu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
PACKAGE=github.com/genshen/ssh-web-console
.PHONY: clean all
all: ssh-web-console-linux-amd64 ssh-web-console-linux-arm64 ssh-web-console-darwin-amd64 ssh-web-console-windows-amd64.exe
ssh-web-console-linux-amd64:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ssh-web-console-linux-amd64 ${PACKAGE}
ssh-web-console-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o ssh-web-console-linux-arm64 ${PACKAGE}
ssh-web-console-darwin-amd64:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o ssh-web-console-darwin-amd64 ${PACKAGE}
ssh-web-console-windows-amd64.exe:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o ssh-web-console-windows-amd64.exe ${PACKAGE}
ssh-web-console :
go build -o ssh-web-console
clean:
rm -f ssh-web-console-linux-amd64 ssh-web-console-linux-arm64 ssh-web-console-darwin-amd64 ssh-web-console-windows-amd64.exe
# web-console # ssh-web-console
you can connect to your linux machine by ssh in your browser.
Consola web para acceso desde Cluster Panel ![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/genshen/ssh-web-console?logo=docker&sort=date)
\ No newline at end of file ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/genshen/ssh-web-console?sort=semver&logo=docker)
![Docker Pulls](https://img.shields.io/docker/pulls/genshen/ssh-web-console?logo=docker)
## Quick start
```bash
$ docker pull genshen/ssh-web-console:latest
# docker build --build-arg GOMODULE=on -t genshen/ssh-web-console . # or build docker image on your own machine
$ docker run -v ${PWD}/conf:/home/web/conf -p 2222:2222 --rm genshen/ssh-web-console
```
Open your browser, visit `http://localhost:2222`. Enjoy it!
**note**: To run docker container, make sure config.yaml file is in directory ${PWD}/conf
## Build & Run
make sure you go version is not less than 1.11
### clone
```bash
git clone --recurse-submodules https://github.com/genshen/ssh-web-console.git
cd ssh-web-console
```
### build frontend
```bash
cd web
yarn install
yarn build
cd ../
```
### build go
```bash
go get github.com/rakyll/statik
statik --src=web/build # use statik tool to convert files in 'web/dist' dir to go code, and compile into binary.
export GO111MODULE=on # for go 1.11.x
go build
```
## Run
run: `./ssh-web-console`, and than you can enjoy it in your browser by visiting `http://localhost:2222`.
## Screenshots
![](./Screenshots/shot2.png)
![](./Screenshots/shot3.png)
![](./Screenshots/shot4.png)
# Related Works
https://github.com/shibingli/webconsole
site:
appname: ssh-web-console
listen_addr: :2222
runmode: prod
deploy_host: console.hpc.gensh.me
prod:
# http path of static files and views
static_prefix: /
dev: # config used in debug mode.
# https prefix of static files only
static_prefix: /static/
# redirect static files requests to this address, redirect "static_prefix" to "static_redirect"
# for example, static_prefix is "/static", static_redirect is "localhost:8080/dist",
# this will redirect all requests having prefix "/static" to "localhost:8080/dist"
static_redirect: "localhost:8080"
static_dir: ./dist/ # if static_redirect is empty, http server will read static file from this dir.
views_prefix: / #
views_dir: views/ # views(html) directory.
ssh:
# io_mode: 1 # the mode reading data from ssh server: channel mode (0) OR session mode (1)
buffer_checker_cycle_time: 60 # check buffer every { buffer_checker_cycle_time } ms. if buffer is not empty , then send buffered data back to client(browser/webSocket)
jwt:
jwt_secret: secret.console.hpc.gensh.me
token_lifetime: 7200
issuer: issuer.ssh.gensh.me
query_token_key: _t
\ No newline at end of file
4. Constantes a completar en ambiente productivo
--------------------------------------------
#archivo web/.env
__REACT_APP_CLUSTER_URL__ #url principal de la instalación de Cluster Panel
\ No newline at end of file
module github.com/genshen/ssh-web-console
go 1.13
require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/oklog/ulid/v2 v2.0.2
github.com/pkg/sftp v1.12.0
github.com/rakyll/statik v0.1.7
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
golang.org/x/tools/gopls v0.7.1 // indirect
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
nhooyr.io/websocket v1.8.6
)
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/safehtml v0.0.2/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jba/templatecheck v0.6.0/go.mod h1:/1k7EajoSErFI9GLHAsiIJEaNLt3ALKNw2TV7z2SYv4=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc=
github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.12.0 h1:/f3b24xrDhkhddlaobPe2JgBqfdt+gC/NYl0QY9IOuI=
github.com/pkg/sftp v1.12.0/go.mod h1:fUqqXB5vEgVCZ131L+9say31RAri6aF6KDViawhxKK8=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ=
github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc=
github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/sanity-io/litter v1.5.0/go.mod h1:5Z71SvaYy5kcGtyglXOC9rrUi3c1E8CamFWjQsazTh0=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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.0.0-20210101214203-2dba1e4ea05c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.6-0.20210802203754-9b21a8868e16 h1:ZC/gVBZl8poJyKzWLxxlsmhayVGosF4mohR35szD5Bg=
golang.org/x/tools v0.1.6-0.20210802203754-9b21a8868e16/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools/gopls v0.7.1 h1:Mh3Z8Xcoq3Zy7ksSlwDV/nzQSbjFf06A+L+F8YHq55U=
golang.org/x/tools/gopls v0.7.1/go.mod h1:keTmqBxKJRaTbzntq7EG7w8Pa7OlOXVqm4rc6Z09gMU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.2.0 h1:ws8AfbgTX3oIczLPNPCu5166oBg9ST2vNs0rcht+mDE=
honnef.co/go/tools v0.2.0/go.mod h1:lPVVZ2BS5TfnjLyizF7o7hv7j9/L+8cZY2hLyjP9cGY=
mvdan.cc/gofumpt v0.1.1 h1:bi/1aS/5W00E2ny5q65w9SnKpWEF/UIOqDYBILpo9rA=
mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48=
mvdan.cc/xurls/v2 v2.2.0 h1:NSZPykBXJFCetGZykLAxaL6SIpvbVy/UFEniIfHAa8A=
mvdan.cc/xurls/v2 v2.2.0/go.mod h1:EV1RMtya9D6G5DMYPGD8zTQzaHet6Jh8gFlRgGRJeO8=
nhooyr.io/websocket v1.8.6 h1:s+C3xAMLwGmlI31Nyn/eAehUlZPwfYZu2JXM621Q5/k=
nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
package main
import (
"flag"
"fmt"
"github.com/genshen/ssh-web-console/src/routers"
"github.com/genshen/ssh-web-console/src/utils"
"log"
"net/http"
)
var confFilePath string
var version bool
func init() {
flag.StringVar(&confFilePath, "config", "conf/config.yaml", "filepath of config file.")
flag.BoolVar(&version, "version", false, "show current version.")
}
func main() {
flag.Parse()
if version {
fmt.Println("v0.3.0")
return
}
if err := utils.InitConfig(confFilePath); err != nil {
log.Fatal("config error,", err)
return
}
routers.Register()
log.Println("listening on port ", utils.Config.Site.ListenAddr)
// listen http
if err := http.ListenAndServe(utils.Config.Site.ListenAddr, nil); err != nil {
log.Fatal(err)
return
}
}
package controllers
import (
"github.com/genshen/ssh-web-console/src/utils"
"log"
"net/http"
"strings"
)
type AfterAuthenticated interface {
// make sure token and session is not nil.
ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, token *utils.Claims, session utils.Session)
ShouldClearSessionAfterExec() bool
}
func AuthPreChecker(i AfterAuthenticated) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var authHead = r.Header.Get("Authorization")
var token string
if authHead != "" {
lIndex := strings.LastIndex(authHead, " ")
if lIndex < 0 || lIndex+1 >= len(authHead) {
utils.Abort(w, "invalid token", 400)
log.Println("Error: invalid token", 400)
return
} else {
token = authHead[lIndex+1:]
}
} else {
if token = r.URL.Query().Get(utils.Config.Jwt.QueryTokenKey); token == "" {
utils.Abort(w, "invalid token", 400)
log.Println("Error: invalid token", 400)
return
} // else token != "", then passed and go on running
}
if claims, err := utils.JwtVerify(token); err != nil {
http.Error(w, "invalid token", 400)
log.Println("Error: Cannot setup WebSocket connection:", err)
} else { // check passed.
// check session.
if session, ok := utils.SessionStorage.Get(token); !ok { // make a session copy.
utils.Abort(w, "Error: Cannot get Session data:", 400)
log.Println("Error: Cannot get Session data for token", token)
} else {
if i.ShouldClearSessionAfterExec() {
defer utils.SessionStorage.Delete(token)
i.ServeAfterAuthenticated(w, r, claims, session)
}else{
i.ServeAfterAuthenticated(w, r, claims, session)
}
}
}
}
}
package files
import (
"github.com/genshen/ssh-web-console/src/utils"
"io"
"log"
"net/http"
"path"
)
type Download struct{}
func (d Download) ShouldClearSessionAfterExec() bool {
return false
}
func (d Download) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) {
cid := r.URL.Query().Get("cid") // get connection id.
if client := utils.ForkSftpClient(cid); client == nil {
utils.Abort(w, "error: lost sftp connection.", 400)
log.Println("Error: lost sftp connection.")
return
} else {
if wd, err := client.Getwd(); err == nil {
relativePath := r.URL.Query().Get("path") // get path.
fullPath := path.Join(wd, relativePath)
if fileInfo, err := client.Stat(fullPath); err == nil && !fileInfo.IsDir() {
if file, err := client.Open(fullPath); err == nil {
defer file.Close()
w.Header().Add("Content-Disposition", "attachment;filename="+fileInfo.Name())
w.Header().Add("Content-Type", "application/octet-stream")
io.Copy(w, file)
return
}
}
}
utils.Abort(w, "no such file", 400)
return
}
}
package files
import (
"github.com/genshen/ssh-web-console/src/models"
"github.com/genshen/ssh-web-console/src/utils"
"github.com/oklog/ulid/v2"
"golang.org/x/crypto/ssh"
"log"
"math/rand"
"net/http"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
"time"
)
type SftpEstablish struct{}
func (e SftpEstablish) ShouldClearSessionAfterExec() bool {
return false
}
// establish webSocket connection to browser to maintain connection with remote sftp server.
// If establish success, add sftp connection to a list.
// and then, handle all message from message (e.g.list files in one directory.).
func (e SftpEstablish) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) {
// init webSocket connection
ws, err := websocket.Accept(w, r, nil)
if err != nil {
http.Error(w, "Cannot setup WebSocket connection:", 400)
log.Println("Error: Cannot setup WebSocket connection:", err)
return
}
defer ws.Close(websocket.StatusNormalClosure, "closed")
// add sftp client to list if success.
user := session.Value.(models.UserInfo)
sftpEntity, err := utils.NewSftpEntity(utils.SftpNode(utils.NewSSHNode(user.Host, user.Port)), user.Username, ssh.Password(user.Password))
if err != nil {
http.Error(w, "Error while establishing sftp connection", 400)
log.Println("Error: while establishing sftp connection", err)
return
}
// generate unique id.
t := time.Now()
entropy := rand.New(rand.NewSource(t.UnixNano()))
id := ulid.MustNew(ulid.Timestamp(t), entropy)
// add sftpEntity to list.
utils.Join(id.String(), sftpEntity) // note:key is not for user auth, but for identify different connections.
defer utils.Leave(id.String()) // close sftp connection anf remove sftpEntity from list.
wsjson.Write(r.Context(), ws, models.SftpWebSocketMessage{Type: models.SftpWebSocketMessageTypeID, Data: id.String()})
// dispatch webSocket Messages.
// process webSocket message one by one at present. todo improvement.
for {
_, _, err := ws.Read(r.Context())
if err != nil {
log.Println("Error: error reading webSocket message:", err)
break
}
//if err = DispatchSftpMessage(msgType, p, sftpEntity.sftpClient); err != nil { // todo handle heartbeat message and so on.
// log.Println("Error: error write data to ssh server:", err)
// break
//}
}
}
package files
import (
"github.com/genshen/ssh-web-console/src/utils"
"net/http"
)
type FileStat struct{}
func (f FileStat) ShouldClearSessionAfterExec() bool {
return false
}
func (f FileStat) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session *utils.Session) {
}
package files
import (
"github.com/pkg/sftp"
"os"
"path"
)
func DispatchSftpMessage(messageType int, message []byte, client *sftp.Client) error {
var fullPath string
if wd, err := client.Getwd(); err == nil {
fullPath = path.Join(wd, "/tmp/")
if _, err := client.Stat(fullPath); err != nil {
if os.IsNotExist(err) {
if err := client.Mkdir(fullPath); err != nil {
return err
}
} else {
return err
}
}
} else {
return err
}
//dstFile, err := client.Create(path.Join(fullPath, header.Filename))
//if err != nil {
// return err
//}
//defer srcFile.Close()
//defer dstFile.Close()
//
//_, err = dstFile.ReadFrom(srcFile)
//if err != nil {
// return err
//}
return nil
}
package files
import (
"github.com/genshen/ssh-web-console/src/models"
"github.com/genshen/ssh-web-console/src/utils"
"log"
"net/http"
"os"
"path"
)
type List struct{}
type Ls struct {
Name string `json:"name"`
Path string `json:"path"` // including Name
Mode os.FileMode `json:"mode"` // todo: use io/fs.FileMode
}
func (f List) ShouldClearSessionAfterExec() bool {
return false
}
func (f List) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) {
response := models.JsonResponse{HasError: true}
cid := r.URL.Query().Get("cid") // get connection id.
if client := utils.ForkSftpClient(cid); client == nil {
utils.Abort(w, "error: lost sftp connection.", 400)
log.Println("Error: lost sftp connection.")
return
} else {
if wd, err := client.Getwd(); err == nil {
relativePath := r.URL.Query().Get("path") // get path.
fullPath := path.Join(wd, relativePath)
if files, err := client.ReadDir(fullPath); err != nil {
response.Addition = "no such path"
} else {
response.HasError = false
fileList := make([]Ls, 0) // this will not be converted to null if slice is empty.
for _, file := range files {
fileList = append(fileList, Ls{Name: file.Name(), Mode: file.Mode(), Path: path.Join(relativePath, file.Name())})
}
response.Message = fileList
}
} else {
response.Addition = "no such path"
}
}
utils.ServeJSON(w, response)
}
package files
import (
"github.com/genshen/ssh-web-console/src/utils"
"github.com/pkg/sftp"
"log"
"mime/multipart"
"net/http"
"path"
)
type FileUpload struct{}
func (f FileUpload) ShouldClearSessionAfterExec() bool {
return false
}
func (f FileUpload) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) {
cid := r.URL.Query().Get("cid") // get connection id.
if sftpClient := utils.ForkSftpClient(cid); sftpClient == nil {
utils.Abort(w, "error: lost sftp connection.", 400)
log.Println("Error: lost sftp connection.")
return
} else {
//file, header, err := this.GetFile("file")
r.ParseMultipartForm(32 << 20)
file, header, err := r.FormFile("file")
relativePath := r.URL.Query().Get("path") // get path. default is ""
if err != nil {
log.Println("Error: getfile err ", err)
utils.Abort(w, "error", 503)
return
}
defer file.Close()
if err := UploadFile(relativePath, sftpClient, file, header); err != nil {
log.Println("Error: sftp error:", err)
utils.Abort(w, "message", 503)
} else {
w.Write([]byte("success")) // todo write file name back.
}
}
}
// upload file to server via sftp.
/**
@desPath: relative path in remote server.
*/
func UploadFile(desPath string, client *sftp.Client, srcFile multipart.File, header *multipart.FileHeader) error {
var fullPath string
if wd, err := client.Getwd(); err == nil {
fullPath = path.Join(wd, desPath)
if _, err := client.Stat(fullPath); err != nil {
return err // check path must exist
}
} else {
return err
}
dstFile, err := client.Create(path.Join(fullPath, header.Filename))
if err != nil {
return err
}
defer srcFile.Close()
defer dstFile.Close()
_, err = dstFile.ReadFrom(srcFile)
if err != nil {
return err
}
return nil
}
package controllers
import (
"github.com/genshen/ssh-web-console/src/models"
"github.com/genshen/ssh-web-console/src/utils"
"golang.org/x/crypto/ssh"
"net/http"
"strconv"
)
func SignIn(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Invalid request method.", 405)
} else {
var err error
var errUnmarshal models.JsonResponse
err = r.ParseForm()
if err != nil {
panic(err)
}
userinfo := models.UserInfo{}
userinfo.Host = r.Form.Get("host")
port := r.Form.Get("port")
userinfo.Username = r.Form.Get("username")
userinfo.Password = r.Form.Get("passwd")
userinfo.Port, err = strconv.Atoi(port)
if err != nil {
userinfo.Port = 22
}
if userinfo.Host != "" && userinfo.Username != "" {
//try to login session account
session := utils.SSHShellSession{}
session.Node = utils.NewSSHNode(userinfo.Host, userinfo.Port)
err := session.Connect(userinfo.Username, ssh.Password(userinfo.Password))
if err != nil {
errUnmarshal = models.JsonResponse{HasError: true, Message: models.SIGN_IN_FORM_TYPE_ERROR_PASSWORD}
} else {
defer session.Close()
// create session
client, err := session.GetClient()
if err != nil {
// bad connection.
return
}
if session, err := client.NewSession(); err == nil {
if err := session.Run("whoami"); err == nil {
if token, expireUnix, err := utils.JwtNewToken(userinfo.JwtConnection, utils.Config.Jwt.Issuer); err == nil {
errUnmarshal = models.JsonResponse{HasError: false, Addition: token}
utils.ServeJSON(w, errUnmarshal)
utils.SessionStorage.Put(token, expireUnix, userinfo)
return
}
}
}
errUnmarshal = models.JsonResponse{HasError: true, Message: models.SIGN_IN_FORM_TYPE_ERROR_TEST}
}
} else {
errUnmarshal = models.JsonResponse{HasError: true, Message: models.SIGN_IN_FORM_TYPE_ERROR_VALID}
}
utils.ServeJSON(w, errUnmarshal)
}
}
package controllers
import (
"encoding/base64"
"encoding/json"
"github.com/genshen/ssh-web-console/src/models"
"golang.org/x/crypto/ssh"
"io"
"nhooyr.io/websocket"
)
func DispatchMessage(sshSession *ssh.Session, messageType websocket.MessageType, wsData []byte, wc io.WriteCloser) error {
var socketData json.RawMessage
socketStream := models.SSHWebSocketMessage{
Data: &socketData,
}
if err := json.Unmarshal(wsData, &socketStream); err != nil {
return nil // skip error
}
switch socketStream.Type {
case models.SSHWebSocketMessageTypeHeartbeat:
return nil
case models.SSHWebSocketMessageTypeResize:
var resize models.WindowResize
if err := json.Unmarshal(socketData, &resize); err != nil {
return nil // skip error
}
sshSession.WindowChange(resize.Rows, resize.Cols)
case models.SSHWebSocketMessageTypeTerminal:
var message models.TerminalMessage
if err := json.Unmarshal(socketData, &message); err != nil {
return nil
}
if decodeBytes, err := base64.StdEncoding.DecodeString(message.DataBase64); err != nil { // todo ignore error
return nil // skip error
} else {
if _, err := wc.Write(decodeBytes); err != nil {
return err
}
}
}
return nil
}
package controllers
import (
"bytes"
"context"
"nhooyr.io/websocket"
"sync"
)
// copy data from WebSocket to ssh server
// and copy data from ssh server to WebSocket
// write data to WebSocket
// the data comes from ssh server.
type WebSocketBufferWriter struct {
buffer bytes.Buffer
mu sync.Mutex
}
// implement Write interface to write bytes from ssh server into bytes.Buffer.
func (w *WebSocketBufferWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.buffer.Write(p)
}
// flush all data in this buff into WebSocket.
func (w *WebSocketBufferWriter) Flush(ctx context.Context, messageType websocket.MessageType, ws *websocket.Conn) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.buffer.Len() != 0 {
err := ws.Write(ctx, messageType, w.buffer.Bytes())
if err != nil {
return err
}
w.buffer.Reset()
}
return nil
}
package controllers
import (
"context"
"fmt"
"github.com/genshen/ssh-web-console/src/models"
"github.com/genshen/ssh-web-console/src/utils"
"golang.org/x/crypto/ssh"
"io"
"log"
"net/http"
"nhooyr.io/websocket"
"time"
)
//const SSH_EGG = `genshen<genshenchu@gmail.com> https://github.com/genshen/sshWebConsole"`
type SSHWebSocketHandle struct {
bufferFlushCycle int
}
func NewSSHWSHandle(bfc int) *SSHWebSocketHandle {
var handle SSHWebSocketHandle
handle.bufferFlushCycle = bfc
return &handle
}
// clear session after ssh closed.
func (c *SSHWebSocketHandle) ShouldClearSessionAfterExec() bool {
return true
}
// handle webSocket connection.
func (c *SSHWebSocketHandle) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) {
// init webSocket connection
conn, err := websocket.Accept(w, r, nil)
if err != nil {
http.Error(w, "Cannot setup WebSocket connection:", 400)
log.Println("Error: Cannot setup WebSocket connection:", err)
return
}
defer conn.Close(websocket.StatusNormalClosure, "closed")
userInfo := session.Value.(models.UserInfo)
cols := utils.GetQueryInt32(r, "cols", 120)
rows := utils.GetQueryInt32(r, "rows", 32)
sshAuth := ssh.Password(userInfo.Password)
if err := c.SSHShellOverWS(r.Context(), conn, claims.Host, claims.Port, userInfo.Username, sshAuth, cols, rows); err != nil {
log.Println("Error,", err)
utils.Abort(w, err.Error(), 500)
}
}
// ssh shell over websocket
// first,we establish a ssh connection to ssh server when a webSocket comes;
// then we deliver ssh data via ssh connection between browser and ssh server.
// That is, read webSocket data from browser (e.g. 'ls' command) and send data to ssh server via ssh connection;
// the other hand, read returned ssh data from ssh server and write back to browser via webSocket API.
func (c *SSHWebSocketHandle) SSHShellOverWS(ctx context.Context, ws *websocket.Conn, host string, port int, username string, auth ssh.AuthMethod, cols, rows uint32) error {
//setup ssh connection
sshEntity := utils.SSHShellSession{
Node: utils.NewSSHNode(host, port),
}
// set io for ssh session
var wsBuff WebSocketBufferWriter
sshEntity.WriterPipe = &wsBuff
var sshConn utils.SSHConnInterface = &sshEntity // set interface
err := sshConn.Connect(username, auth)
if err != nil {
return fmt.Errorf("cannot setup ssh connection %w", err)
}
defer sshConn.Close()
// config ssh
sshSession, err := sshConn.Config(cols, rows)
if err != nil {
return fmt.Errorf("configure ssh error: %w", err)
}
// an egg:
//if err := sshSession.Setenv("SSH_EGG", SSH_EGG); err != nil {
// log.Println(err)
//}
// after configure, the WebSocket is ok.
defer wsBuff.Flush(ctx, websocket.MessageBinary, ws)
done := make(chan bool, 3)
setDone := func() { done <- true }
// most messages are ssh output, not webSocket input
writeMessageToSSHServer := func(wc io.WriteCloser) { // read messages from webSocket
defer setDone()
for {
msgType, p, err := ws.Read(ctx)
// if WebSocket is closed by some reason, then this func will return,
// and 'done' channel will be set, the outer func will reach to the end.
// then ssh session will be closed in defer.
if err != nil {
log.Println("Error: error reading webSocket message:", err)
return
}
if err = DispatchMessage(sshSession, msgType, p, wc); err != nil {
log.Println("Error: error write data to ssh server:", err)
return
}
}
}
stopper := make(chan bool) // timer stopper
// check webSocketWriterBuffer(if not empty,then write back to webSocket) every 120 ms.
writeBufferToWebSocket := func() {
defer setDone()
tick := time.NewTicker(time.Millisecond * time.Duration(c.bufferFlushCycle))
//for range time.Tick(120 * time.Millisecond){}
defer tick.Stop()
for {
select {
case <-tick.C:
if err := wsBuff.Flush(ctx, websocket.MessageBinary, ws); err != nil {
log.Println("Error: error sending data via webSocket:", err)
return
}
case <-stopper:
return
}
}
}
go writeMessageToSSHServer(sshEntity.StdinPipe)
go writeBufferToWebSocket()
go func() {
defer setDone()
if err := sshSession.Wait(); err != nil {
log.Println("ssh exist from server", err)
}
// if ssh is closed (wait returns), then 'done', web socket will be closed.
// by the way, buffered data will be flushed before closing WebSocket.
}()
<-done
stopper <- true // stop tick timer(if tick is finished by due to the bad WebSocket, this line will just only set channel(no bad effect). )
log.Println("Info: websocket finished!")
return nil
}
package models
func init() {
}
package models
const (
SftpWebSocketMessageTypeHeartbeat = "heartbeat"
SftpWebSocketMessageTypeID = "cid"
)
type SftpWebSocketMessage struct {
Type string `json:"type"`
Data interface{} `json:"data"` // json.RawMessage
}
package models
import "github.com/genshen/ssh-web-console/src/utils"
const (
SIGN_IN_FORM_TYPE_ERROR_VALID = iota
SIGN_IN_FORM_TYPE_ERROR_PASSWORD
SIGN_IN_FORM_TYPE_ERROR_TEST
)
type UserInfo struct {
utils.JwtConnection
Username string `json:"username"`
Password string `json:"-"`
}
type JsonResponse struct {
HasError bool `json:"has_error"`
Message interface{} `json:"message"`
Addition interface{} `json:"addition"`
}
package models
const (
SSHWebSocketMessageTypeTerminal = "terminal"
SSHWebSocketMessageTypeHeartbeat = "heartbeat"
SSHWebSocketMessageTypeResize = "resize"
)
type SSHWebSocketMessage struct {
Type string `json:"type"`
Data interface{} `json:"data"` // json.RawMessage
}
// normal terminal message
type TerminalMessage struct {
DataBase64 string `json:"base64"`
}
// terminal window resize
type WindowResize struct {
Cols int `json:"cols"`
Rows int `json:"rows"`
}
package routers
import (
"github.com/genshen/ssh-web-console/src/controllers"
"github.com/genshen/ssh-web-console/src/controllers/files"
"github.com/genshen/ssh-web-console/src/utils"
_ "github.com/genshen/ssh-web-console/statik"
"github.com/rakyll/statik/fs"
"log"
"net/http"
"os"
)
const (
RunModeDev = "dev"
RunModeProd = "prod"
)
func Register() {
// serve static files
// In dev mode, resource files (for example /static/*) and views(fro example /index.html) are served separately.
// In production mode, resource files and views are served by statikFS (for example /*).
if utils.Config.Site.RunMode == RunModeDev {
if utils.Config.Dev.StaticPrefix == utils.Config.Dev.ViewsPrefix {
log.Fatal(`static prefix and views prefix can not be the same, check your config.`)
return
}
// server resource files
if utils.Config.Dev.StaticRedirect == "" {
// serve locally
localFile := justFilesFilesystem{http.Dir(utils.Config.Dev.StaticDir)}
http.Handle(utils.Config.Dev.StaticPrefix, http.StripPrefix(utils.Config.Dev.StaticPrefix, http.FileServer(localFile)))
} else {
// serve by redirection
http.HandleFunc(utils.Config.Dev.StaticPrefix, func(writer http.ResponseWriter, req *http.Request) {
http.Redirect(writer, req, utils.Config.Dev.StaticRedirect+req.URL.Path, http.StatusMovedPermanently)
})
}
// serve views files.
utils.MemStatic(utils.Config.Dev.ViewsDir)
http.HandleFunc(utils.Config.Dev.ViewsPrefix, func(w http.ResponseWriter, r *http.Request) {
utils.ServeHTTP(w, r) // server soft static files.
})
} else {
statikFS, err := fs.New()
if err != nil {
log.Fatal(err)
}
http.Handle(utils.Config.Prod.StaticPrefix, http.StripPrefix(utils.Config.Prod.StaticPrefix, http.FileServer(statikFS)))
}
bct := utils.Config.SSH.BufferCheckerCycleTime
// api
http.HandleFunc("/api/signin", controllers.SignIn)
http.HandleFunc("/api/sftp/upload", controllers.AuthPreChecker(files.FileUpload{}))
http.HandleFunc("/api/sftp/ls", controllers.AuthPreChecker(files.List{}))
http.HandleFunc("/api/sftp/dl", controllers.AuthPreChecker(files.Download{}))
http.HandleFunc("/ws/ssh", controllers.AuthPreChecker(controllers.NewSSHWSHandle(bct)))
http.HandleFunc("/ws/sftp", controllers.AuthPreChecker(files.SftpEstablish{}))
}
/*
* disable directory index, code from https://groups.google.com/forum/#!topic/golang-nuts/bStLPdIVM6w
*/
type justFilesFilesystem struct {
fs http.FileSystem
}
func (fs justFilesFilesystem) Open(name string) (http.File, error) {
f, err := fs.fs.Open(name)
if err != nil {
return nil, err
}
return neuteredReaddirFile{f}, nil
}
type neuteredReaddirFile struct {
http.File
}
func (f neuteredReaddirFile) Readdir(count int) ([]os.FileInfo, error) {
return nil, nil
}
package utils
import (
"gopkg.in/yaml.v3"
"io/ioutil"
"os"
)
var Config struct {
Site struct {
AppName string `yaml:"app_name"`
RunMode string `yaml:"runmode"`
DeployHost string `yaml:"deploy_host"`
ListenAddr string `yaml:"listen_addr"`
} `yaml:"site"`
Prod struct {
StaticPrefix string `yaml:"static_prefix"` // http prefix of static and views files
} `yaml:"prod"`
Dev struct {
StaticPrefix string `yaml:"static_prefix"` // https prefix of only static files
//StaticPrefix string `yaml:"static_prefix"` // prefix of static files in dev mode.
// redirect static files requests to this address, redirect "StaticPrefix" to "StaticRedirect + StaticPrefix"
// for example, StaticPrefix is "static", StaticRedirect is "localhost:8080/dist",
// this will redirect all requests having prefix "static" to "localhost:8080/dist/"
StaticRedirect string `yaml:"static_redirect"`
// http server will read static file from this dir if StaticRedirect is empty
StaticDir string `yaml:"static_dir"`
ViewsPrefix string `yaml:"views_prefix"` // https prefix of only views files
// path of view files (we can not redirect view files) to be served.
ViewsDir string `yaml:"views_dir"` // todo
} `yaml:"dev"`
SSH struct {
BufferCheckerCycleTime int `yaml:"buffer_checker_cycle_time"`
} `yaml:"ssh"`
Jwt struct {
Secret string `yaml:"jwt_secret"`
TokenLifetime int64 `yaml:"token_lifetime"`
Issuer string `yaml:"issuer"`
QueryTokenKey string `yaml:"query_token_key"`
} `yaml:"jwt"`
}
func InitConfig(filepath string) error {
f, err := os.Open(filepath)
if err != nil {
return err
}
defer f.Close()
content, err := ioutil.ReadAll(f)
if err != nil {
return err
}
err = yaml.Unmarshal(content, &Config)
if err != nil {
return err
}
return nil
}
package utils
import (
"encoding/json"
"log"
"net/http"
"strconv"
)
func ServeJSON(w http.ResponseWriter, j interface{}) {
if j == nil {
http.Error(w, "empty response data", 400)
return
}
// w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(j)
// for request: json.NewDecoder(res.Body).Decode(&body)
}
func Abort(w http.ResponseWriter, message string, code int) {
http.Error(w, message, code)
}
func GetQueryInt(r *http.Request, key string, defaultValue int) int {
value, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil {
log.Println("Error: get params cols error:", err)
return defaultValue
}
return value
}
func GetQueryInt32(r *http.Request, key string, defaultValue uint32) uint32 {
value, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil {
log.Println("Error: get params cols error:", err)
return defaultValue
}
return uint32(value)
}
package utils
import (
"errors"
"fmt"
"github.com/dgrijalva/jwt-go"
"time"
)
// payload in jwt
type JwtConnection struct {
Host string
Port int
}
type Claims struct {
JwtConnection
jwt.StandardClaims
}
// create a jwt token,and return this token as string type.
// we can create a new token with Claims in it if login is successful.
// then, we can known the host and port when setting up websocket or sftp.
func JwtNewToken(connection JwtConnection, issuer string) (tokenString string, expire int64, err error) {
expireToken := time.Now().Add(time.Second * time.Duration(Config.Jwt.TokenLifetime)).Unix()
// We'll manually assign the claims but in production you'd insert values from a database
claims := Claims{
JwtConnection: connection,
StandardClaims: jwt.StandardClaims{
ExpiresAt: expireToken,
Issuer: issuer,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Signs the token with a secret.
if signedToken, err := token.SignedString([]byte(Config.Jwt.Secret)); err != nil {
return "", 0, err
} else {
return signedToken, expireToken, nil
}
}
// Verify a jwt token
func JwtVerify(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Make sure token's signature wasn't changed
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected siging method")
}
return []byte(Config.Jwt.Secret), nil
})
if err == nil {
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
}
return nil, errors.New("unauthenticated")
}
package utils
import (
"time"
"sync"
)
var SessionStorage SessionManager
var mutex = new(sync.RWMutex)
func init() {
SessionStorage.new()
}
// use jwt string as session key,
// store user information(username and password) in Session.
type SessionManager struct {
sessions map[string]Session
}
type Session struct {
expire int64
Value interface{}
}
func (s *Session) isExpired(timeNow int64) bool {
if s.expire < timeNow {
return true
}
return false
}
func (s *SessionManager) new() {
s.sessions = make(map[string]Session)
}
/**
* add a new session to session manager.
* @params:token: token string
* expire: unix time for expire
* password: ssh user password
*/
func (s *SessionManager) Put(key string, expire int64, value interface{}) {
s.gc()
mutex.Lock()
s.sessions[key] = Session{expire: expire, Value: value}
mutex.Unlock()
}
func (s *SessionManager) Get(key string) (sessionData Session, exist bool) {
mutex.RLock()
defer mutex.RUnlock()
session, ok := s.sessions[key]
return session, ok
}
func (s *SessionManager) Delete(key string) {
mutex.Lock()
if _, ok := s.sessions[key]; ok {
delete(s.sessions, key)
}
mutex.Unlock()
}
func (s *SessionManager) gc() {
timeNow := time.Now().Unix()
mutex.Lock()
for key, session := range s.sessions {
if session.isExpired(timeNow) {
delete(s.sessions, key)
}
}
mutex.Unlock()
}
package utils
import (
"fmt"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"sync"
)
type SftpNode Node // struct alias.
type SftpEntity struct {
sshEntity *SSHShellSession // from utils/ssh_utils
sftpClient *sftp.Client // sftp session created by sshEntity.client..
}
// close sftp session and ssh client
func (con *SftpEntity) Close() error {
var e error = nil
// close sftp client
if err := con.sftpClient.Close(); err != nil { // todo for debug.
e = err
}
// close ssh
if err := con.sshEntity.Close(); err != nil && e != nil {
return fmt.Errorf("error closing sftp: %w: %s", err, e)
} else if err != nil { // e is nil
return fmt.Errorf("error closing sftp: %w", err)
}
return e
}
var (
sftpMutex = new(sync.RWMutex)
subscribers = make(map[string]SftpEntity)
)
func NewSftpEntity(user SftpNode, username string, auth ssh.AuthMethod) (SftpEntity, error) {
sshEntity := SSHShellSession{
Node: NewSSHNode(user.Host, user.Port),
}
// init ssh connection.
err := sshEntity.Connect(username, auth)
if err != nil {
return SftpEntity{}, err
}
// make a new sftp client
if sshClient, err := sshEntity.GetClient(); err != nil {
return SftpEntity{}, err
} else {
client, err := sftp.NewClient(sshClient)
if err != nil {
return SftpEntity{}, err
}
return SftpEntity{sshEntity: &sshEntity, sftpClient: client}, nil
}
}
// add a sftp client to subscribers list.
func Join(key string, sftpEntity SftpEntity) {
sftpMutex.Lock()
//subscribers.PushBack(client)
if c, ok := subscribers[key]; ok {
c.Close() // if client have exists, close the client.
}
subscribers[key] = sftpEntity // store sftpEntity.
sftpMutex.Unlock()
}
// make a copy of SftpEntity matched with given key.
// return sftpEntity and exist flag (bool).
func Fork(key string) (SftpEntity, bool) {
sftpMutex.Lock()
defer sftpMutex.Unlock()
//subscribers.PushBack(client)
if c, ok := subscribers[key]; ok {
return c, true
} else {
return SftpEntity{}, false
}
}
// make a copy of SftpEntity matched with given key.
// return sftp.client pointer or nil pointer.
func ForkSftpClient(key string) *sftp.Client {
sftpMutex.Lock()
defer sftpMutex.Unlock()
//subscribers.PushBack(client)
if c, ok := subscribers[key]; ok {
return c.sftpClient
} else {
return nil
}
}
// remove a sftp client by key.
func Leave(key string) {
sftpMutex.Lock()
//subscribers.PushBack(client)
if c, ok := subscribers[key]; ok {
c.Close() // close the client.
delete(subscribers, key) // remove from map.
}
sftpMutex.Unlock()
}
package utils
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/ioutil"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// serve all views files from memory storage.
// basic idea: https://github.com/bouk/staticfiles
type staticFilesFile struct {
data []byte
mime string
mtime time.Time
// size is the size before compression. If 0, it means the data is uncompressed
size int64
// hash is a sha256 hash of the file contents. Used for the Etag, and useful for caching
hash string
}
var staticFiles = make(map[string]*staticFilesFile)
// NotFound is called when no asset is found.
// It defaults to http.NotFound but can be overwritten
var NotFound = http.NotFound
// read all files in views directory and map to "staticFiles"
func MemStatic(staticDir string) {
files := processDir(staticDir, "")
for _, file := range files {
var b bytes.Buffer
var b2 bytes.Buffer
hash := sha256.New()
f, err := os.Open(filepath.Join(staticDir, file))
if err != nil {
log.Fatal(err)
}
stat, err := f.Stat()
if err != nil {
log.Fatal(err)
}
if _, err := b.ReadFrom(f); err != nil {
log.Fatal(err)
}
f.Close()
compressedWriter, _ := gzip.NewWriterLevel(&b2, gzip.BestCompression)
writer := io.MultiWriter(compressedWriter, hash)
if _, err := writer.Write(b.Bytes()); err != nil {
log.Fatal(err)
}
compressedWriter.Close()
file = strings.Replace(file, "\\", "/", -1)
if b2.Len() < b.Len() {
staticFiles[file] = &staticFilesFile{
data: b2.Bytes(),
mime: mime.TypeByExtension(filepath.Ext(file)),
mtime: time.Unix(stat.ModTime().Unix(), 0),
size: stat.Size(),
hash: hex.EncodeToString(hash.Sum(nil)),
}
} else {
staticFiles[file] = &staticFilesFile{
data: b.Bytes(),
mime: mime.TypeByExtension(filepath.Ext(file)),
mtime: time.Unix(stat.ModTime().Unix(), 0),
hash: hex.EncodeToString(hash.Sum(nil)),
}
}
b.Reset()
b2.Reset()
hash.Reset()
}
}
// todo large memory!!
func processDir(prefix, dir string) (fileSlice []string) {
files, err := ioutil.ReadDir(filepath.Join(prefix, dir))
var allFiles []string
if err != nil {
log.Fatal(err)
}
for _, file := range files {
if strings.HasPrefix(file.Name(), ".") {
continue
}
dir := filepath.Join(dir, file.Name())
//if skipFile(path.Join(id...), excludeSlice) {
// continue
//}
if file.IsDir() {
for _, v := range processDir(prefix, dir) {
allFiles = append(allFiles, v)
}
} else {
allFiles = append(allFiles, dir)
}
}
return allFiles
}
// ServeHTTP serves a request, attempting to reply with an embedded file.
func ServeHTTP(rw http.ResponseWriter, req *http.Request) {
filename := strings.TrimPrefix(req.URL.Path, "/")
if f, ok := staticFiles[filename]; ok {
serveHTTPByName(rw, req, f)
return
}
// try index.html
if strings.HasSuffix(req.URL.Path, "/") {
filename += "index.html"
if f, ok := staticFiles[filename]; ok {
serveHTTPByName(rw, req, f)
return
}
}
// return 404 if both of them not exists
NotFound(rw, req)
}
// ServeHTTPByName serves a request by the key(param filename) in map.
func serveHTTPByName(rw http.ResponseWriter, req *http.Request, f *staticFilesFile) {
header := rw.Header()
if f.hash != "" {
if hash := req.Header.Get("If-None-Match"); hash == f.hash {
rw.WriteHeader(http.StatusNotModified)
return
}
header.Set("ETag", f.hash)
}
if !f.mtime.IsZero() {
if t, err := time.Parse(http.TimeFormat, req.Header.Get("If-Modified-Since")); err == nil && f.mtime.Before(t.Add(1*time.Second)) {
rw.WriteHeader(http.StatusNotModified)
return
}
header.Set("Last-Modified", f.mtime.UTC().Format(http.TimeFormat))
}
header.Set("Content-Type", f.mime)
// Check if the asset is compressed in the binary
if f.size == 0 { // not compressed
header.Set("Content-Length", strconv.Itoa(len(f.data)))
rw.Write(f.data)
} else {
if header.Get("Content-Encoding") == "" && strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") {
header.Set("Content-Encoding", "gzip")
header.Set("Content-Length", strconv.Itoa(len(f.data)))
rw.Write(f.data)
} else {
header.Set("Content-Length", strconv.Itoa(int(f.size)))
reader, _ := gzip.NewReader(bytes.NewReader(f.data))
io.Copy(rw, reader)
reader.Close()
}
}
}
// Server is simply ServeHTTP but wrapped in http.HandlerFunc so it can be passed into net/http functions directly.
var Server http.Handler = http.HandlerFunc(ServeHTTP)
// Open allows you to read an embedded file directly. It will return a decompressing Reader if the file is embedded in compressed format.
// You should close the Reader after you're done with it.
func Open(name string) (io.ReadCloser, error) {
f, ok := staticFiles[name]
if !ok {
return nil, fmt.Errorf("Asset %s not found", name)
}
if f.size == 0 {
return ioutil.NopCloser(bytes.NewReader(f.data)), nil
}
return gzip.NewReader(bytes.NewReader(f.data))
}
// ModTime returns the modification time of the original file.
// Useful for caching purposes
// Returns zero time if the file is not in the bundle
func ModTime(file string) (t time.Time) {
if f, ok := staticFiles[file]; ok {
t = f.mtime
}
return
}
// Hash returns the hex-encoded SHA256 hash of the original file
// Used for the Etag, and useful for caching
// Returns an empty string if the file is not in the bundle
func Hash(file string) (s string) {
if f, ok := staticFiles[file]; ok {
s = f.hash
}
return
}
package utils
import (
"errors"
"fmt"
"golang.org/x/crypto/ssh"
"io"
"log"
"net"
"strconv"
)
const (
SSH_IO_MODE_CHANNEL = 0
SSH_IO_MODE_SESSION = 1
)
type SSHConnInterface interface {
// close ssh connection
Close() error
// connect using username and password
Connect(username string, auth ssh.AuthMethod) error
// config connection after connected and may also create a ssh session.
Config(cols, rows uint32) (*ssh.Session, error)
}
type Node struct {
Host string // host, e.g: ssh.example.com
Port int //port,default value is 22
client *ssh.Client
}
func NewSSHNode(host string, port int) Node {
return Node{Host: host, Port: port, client: nil}
}
func (node *Node) GetClient() (*ssh.Client, error) {
if node.client == nil {
return nil, errors.New("client is not set")
}
return node.client, nil
}
//see: http://www.nljb.net/default/Go-SSH-%E4%BD%BF%E7%94%A8/
// establish a ssh connection. if success return nil, than can operate ssh connection via pointer Node.client in struct Node.
func (node *Node) Connect(username string, auth ssh.AuthMethod) error {
//var hostKey ssh.PublicKey
// An SSH client is represented with a ClientConn.
//
// To authenticate with the remote server you must pass at least one
// implementation of AuthMethod via the Auth field in ClientConfig,
// and provide a HostKeyCallback.
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
auth,
},
//HostKeyCallback: ssh.FixedHostKey(hostKey),
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
}
client, err := ssh.Dial("tcp", node.Host+":"+strconv.Itoa(node.Port), config)
if err != nil {
return err
}
node.client = client
return nil
}
// connect to ssh server using ssh session.
type SSHShellSession struct {
Node
// calling Write() to write data to ssh server
StdinPipe io.WriteCloser
// Write() be called to receive data from ssh server
WriterPipe io.Writer
session *ssh.Session
}
// setup ssh shell session
// set SSHShellSession.session and StdinPipe from created session here.
// and Session.Stdout and Session.Stderr are also set for outputting.
// Return value is a pointer of ssh session which is created by ssh client for shell interaction.
// If it has error in this func, ssh session will be nil.
func (s *SSHShellSession) Config(cols, rows uint32) (*ssh.Session, error) {
session, err := s.client.NewSession()
if err != nil {
return nil, err
}
s.session = session
// we set stdin, then we can write data to ssh server via this stdin.
// but, as for reading data from ssh server, we can set Session.Stdout and Session.Stderr
// to receive data from ssh server, and write back to somewhere.
if stdin, err := session.StdinPipe(); err != nil {
log.Fatal("failed to set IO stdin: ", err)
return nil, err
} else {
// in fact, stdin it is channel.
s.StdinPipe = stdin
}
// set writer, such the we can receive ssh server's data and write the data to somewhere specified by WriterPipe.
if s.WriterPipe == nil {
return nil, errors.New("WriterPipe is nil")
}
session.Stdout = s.WriterPipe
session.Stderr = s.WriterPipe
modes := ssh.TerminalModes{
ssh.ECHO: 1, // disable echo
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
}
// Request pseudo terminal
if err := session.RequestPty("xterm", int(rows), int(cols), modes); err != nil {
log.Fatal("request for pseudo terminal failed: ", err)
return nil, err
}
// Start remote shell
if err := session.Shell(); err != nil {
log.Fatal("failed to start shell: ", err)
return nil, err
}
return session, nil
}
func (s *SSHShellSession) Close() error {
var e error = nil
// close session first
if s.session != nil {
if err := s.session.Close(); err != nil {
e = err
}
}
// try to close client
if s.client != nil {
if err := s.client.Close(); err != nil && e != nil {
return fmt.Errorf("error closing ssh client: %w: %s", err, e.Error())
} else if err != nil { // e is nil
return fmt.Errorf("error closing ssh client: %w", err)
}
}
return e
}
// deprecated, use session SSHShellSession instead
// connect to ssh server using channel.
type SSHShellChannel struct {
Node
Channel ssh.Channel
}
type ptyRequestMsg struct {
Term string
Columns uint32
Rows uint32
Width uint32
Height uint32
Modelist string
}
func (ch *SSHShellChannel) Config(cols, rows uint32) error {
channel, requests, err := ch.client.Conn.OpenChannel("session", nil)
if err != nil {
return err
}
ch.Channel = channel
go func() {
for req := range requests {
if req.WantReply {
req.Reply(false, nil)
}
}
}()
//see https://github.com/golang/crypto/blob/master/ssh/example_test.go
modes := ssh.TerminalModes{ //todo configure
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
var modeList []byte
for k, v := range modes {
kv := struct {
Key byte
Val uint32
}{k, v}
modeList = append(modeList, ssh.Marshal(&kv)...)
}
modeList = append(modeList, 0)
req := ptyRequestMsg{ //todo configure
Term: "xterm",
Columns: cols,
Rows: rows,
Width: cols * 8,
Height: rows * 8,
Modelist: string(modeList),
}
ok, err := channel.SendRequest("pty-req", true, ssh.Marshal(&req))
if !ok || err != nil {
return errors.New("error sending pty-request" +
func() string {
if err == nil {
return ""
}
return err.Error()
}())
}
ok, err = channel.SendRequest("shell", true, nil)
if !ok || err != nil {
return errors.New("error sending shell-request" +
func() string {
if err == nil {
return ""
}
return err.Error()
}())
}
return nil
}
package test
import (
"testing"
)
func init() {
}
// TestMain is a sample to run an endpoint test
func TestMain(m *testing.M) {
}
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
REACT_APP_CLUSTER_URL='__REACT_APP_CLUSTER_URL__'
\ No newline at end of file
[{"C:\\ProgramasGO\\ssh-web-console\\web\\src\\index.tsx":"1","C:\\ProgramasGO\\ssh-web-console\\web\\src\\serviceWorkerRegistration.ts":"2","C:\\ProgramasGO\\ssh-web-console\\web\\src\\reportWebVitals.ts":"3","C:\\ProgramasGO\\ssh-web-console\\web\\src\\locales\\i18n.ts":"4","C:\\ProgramasGO\\ssh-web-console\\web\\src\\config\\config.ts":"5","C:\\ProgramasGO\\ssh-web-console\\web\\src\\App.tsx":"6","C:\\ProgramasGO\\ssh-web-console\\web\\src\\locales\\locales.ts":"7","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\Home.tsx":"8","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\Console.tsx":"9","C:\\ProgramasGO\\ssh-web-console\\web\\src\\config\\api_routers.ts":"10","C:\\ProgramasGO\\ssh-web-console\\web\\src\\libs\\terminal-resize.ts":"11","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\term\\term_theme.ts":"12","C:\\ProgramasGO\\ssh-web-console\\web\\src\\libs\\utils.ts":"13","C:\\ProgramasGO\\ssh-web-console\\web\\src\\libs\\sshwebsocket.ts":"14","C:\\ProgramasGO\\ssh-web-console\\web\\src\\libs\\string_format.ts":"15","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\Signin.tsx":"16","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\FileTrans.tsx":"17","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\layout\\Header.tsx":"18","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\layout\\Footer.tsx":"19","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\term\\XTerm.tsx":"20","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\files\\files_types.ts":"21","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\PathNav.tsx":"22","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\SftpUpload.tsx":"23","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\files\\GridFileItem.tsx":"24","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\files\\DownloadingFile.tsx":"25","C:\\ProgramasGO\\web-console\\web\\src\\index.tsx":"26","C:\\ProgramasGO\\web-console\\web\\src\\serviceWorkerRegistration.ts":"27","C:\\ProgramasGO\\web-console\\web\\src\\reportWebVitals.ts":"28","C:\\ProgramasGO\\web-console\\web\\src\\config\\config.ts":"29","C:\\ProgramasGO\\web-console\\web\\src\\locales\\i18n.ts":"30","C:\\ProgramasGO\\web-console\\web\\src\\App.tsx":"31","C:\\ProgramasGO\\web-console\\web\\src\\locales\\locales.ts":"32","C:\\ProgramasGO\\web-console\\web\\src\\components\\Console.tsx":"33","C:\\ProgramasGO\\web-console\\web\\src\\components\\Home.tsx":"34","C:\\ProgramasGO\\web-console\\web\\src\\config\\api_routers.ts":"35","C:\\ProgramasGO\\web-console\\web\\src\\components\\term\\term_theme.ts":"36","C:\\ProgramasGO\\web-console\\web\\src\\libs\\terminal-resize.ts":"37","C:\\ProgramasGO\\web-console\\web\\src\\libs\\utils.ts":"38","C:\\ProgramasGO\\web-console\\web\\src\\libs\\sshwebsocket.ts":"39","C:\\ProgramasGO\\web-console\\web\\src\\libs\\string_format.ts":"40","C:\\ProgramasGO\\web-console\\web\\src\\components\\FileTrans.tsx":"41","C:\\ProgramasGO\\web-console\\web\\src\\components\\Signin.tsx":"42","C:\\ProgramasGO\\web-console\\web\\src\\components\\term\\XTerm.tsx":"43","C:\\ProgramasGO\\web-console\\web\\src\\components\\layout\\Header.tsx":"44","C:\\ProgramasGO\\web-console\\web\\src\\components\\files\\files_types.ts":"45","C:\\ProgramasGO\\web-console\\web\\src\\components\\SftpUpload.tsx":"46","C:\\ProgramasGO\\web-console\\web\\src\\components\\PathNav.tsx":"47","C:\\ProgramasGO\\web-console\\web\\src\\components\\files\\GridFileItem.tsx":"48","C:\\ProgramasGO\\web-console\\web\\src\\components\\files\\DownloadingFile.tsx":"49"},{"size":1034,"mtime":1630768787964,"results":"50","hashOfConfig":"51"},{"size":5435,"mtime":1630760757965,"results":"52","hashOfConfig":"51"},{"size":440,"mtime":1630760788779,"results":"53","hashOfConfig":"51"},{"size":1628,"mtime":1630324329302,"results":"54","hashOfConfig":"51"},{"size":1657,"mtime":1630324302839,"results":"55","hashOfConfig":"51"},{"size":405,"mtime":1630324158904,"results":"56","hashOfConfig":"51"},{"size":6835,"mtime":1630324344127,"results":"57","hashOfConfig":"51"},{"size":1725,"mtime":1630354122775,"results":"58","hashOfConfig":"51"},{"size":10145,"mtime":1630290972581,"results":"59","hashOfConfig":"51"},{"size":670,"mtime":1630324289991,"results":"60","hashOfConfig":"51"},{"size":675,"mtime":1630324268633,"results":"61","hashOfConfig":"51"},{"size":1154,"mtime":1630324391856,"results":"62","hashOfConfig":"51"},{"size":1412,"mtime":1630324319263,"results":"63","hashOfConfig":"51"},{"size":1919,"mtime":1630324271105,"results":"64","hashOfConfig":"51"},{"size":357,"mtime":1630324270079,"results":"65","hashOfConfig":"51"},{"size":5759,"mtime":1630796150514,"results":"66","hashOfConfig":"51"},{"size":14281,"mtime":1630290983845,"results":"67","hashOfConfig":"51"},{"size":684,"mtime":1630290955236,"results":"68","hashOfConfig":"51"},{"size":973,"mtime":1630290867923,"results":"69","hashOfConfig":"51"},{"size":7409,"mtime":1630324390675,"results":"70","hashOfConfig":"51"},{"size":586,"mtime":1630324355567,"results":"71","hashOfConfig":"51"},{"size":2017,"mtime":1630290993235,"results":"72","hashOfConfig":"51"},{"size":4049,"mtime":1630291005021,"results":"73","hashOfConfig":"51"},{"size":5416,"mtime":1630290947773,"results":"74","hashOfConfig":"51"},{"size":1508,"mtime":1630291088613,"results":"75","hashOfConfig":"51"},{"size":1034,"mtime":1630768787964,"results":"76","hashOfConfig":"77"},{"size":5435,"mtime":1630760757965,"results":"78","hashOfConfig":"77"},{"size":440,"mtime":1630760788779,"results":"79","hashOfConfig":"77"},{"size":1657,"mtime":1630324302839,"results":"80","hashOfConfig":"77"},{"size":1628,"mtime":1630324329302,"results":"81","hashOfConfig":"77"},{"size":405,"mtime":1630324158904,"results":"82","hashOfConfig":"77"},{"size":6835,"mtime":1630324344127,"results":"83","hashOfConfig":"77"},{"size":10145,"mtime":1630290972581,"results":"84","hashOfConfig":"77"},{"size":1725,"mtime":1630354122775,"results":"85","hashOfConfig":"77"},{"size":670,"mtime":1630324289991,"results":"86","hashOfConfig":"77"},{"size":1154,"mtime":1630324391856,"results":"87","hashOfConfig":"77"},{"size":675,"mtime":1630324268633,"results":"88","hashOfConfig":"77"},{"size":1412,"mtime":1630324319263,"results":"89","hashOfConfig":"77"},{"size":1919,"mtime":1630324271105,"results":"90","hashOfConfig":"77"},{"size":357,"mtime":1630324270079,"results":"91","hashOfConfig":"77"},{"size":14281,"mtime":1630290983845,"results":"92","hashOfConfig":"77"},{"size":5759,"mtime":1630796150514,"results":"93","hashOfConfig":"77"},{"size":7409,"mtime":1630324390675,"results":"94","hashOfConfig":"77"},{"size":684,"mtime":1630290955236,"results":"95","hashOfConfig":"77"},{"size":586,"mtime":1630324355567,"results":"96","hashOfConfig":"77"},{"size":4049,"mtime":1630291005021,"results":"97","hashOfConfig":"77"},{"size":2017,"mtime":1630290993235,"results":"98","hashOfConfig":"77"},{"size":5416,"mtime":1630290947773,"results":"99","hashOfConfig":"77"},{"size":1508,"mtime":1630291088613,"results":"100","hashOfConfig":"77"},{"filePath":"101","messages":"102","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},"1d40y0f",{"filePath":"104","messages":"105","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"106","messages":"107","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"108","messages":"109","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"110","messages":"111","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"112","messages":"113","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"114","messages":"115","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"116","messages":"117","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"118","usedDeprecatedRules":"103"},{"filePath":"119","messages":"120","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"121","messages":"122","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"123","messages":"124","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"125","messages":"126","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"127","messages":"128","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"129","messages":"130","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"131","messages":"132","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"133","messages":"134","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"135","usedDeprecatedRules":"103"},{"filePath":"136","messages":"137","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"138","messages":"139","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"140","messages":"141","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"142"},{"filePath":"143","messages":"144","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"145","messages":"146","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"147","messages":"148","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"149","messages":"150","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"151","messages":"152","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"103"},{"filePath":"153","messages":"154","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"155"},{"filePath":"156","messages":"157","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1b6yqyj",{"filePath":"158","messages":"159","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"160","messages":"161","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"162","messages":"163","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"164","messages":"165","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"166","messages":"167","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"168","messages":"169","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"170","messages":"171","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"172","messages":"173","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"174","messages":"175","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"176","messages":"177","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"178","messages":"179","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"180","messages":"181","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"182","messages":"183","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"184","messages":"185","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"186","messages":"187","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"188","messages":"189","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"190","messages":"191","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"192","messages":"193","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"194","messages":"195","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"196","messages":"197","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"198","messages":"199","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"200","messages":"201","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"202","messages":"203","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"C:\\ProgramasGO\\ssh-web-console\\web\\src\\index.tsx",[],[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\serviceWorkerRegistration.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\reportWebVitals.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\locales\\i18n.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\config\\config.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\App.tsx",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\locales\\locales.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\Home.tsx",["204"],"import React from 'react';\r\nimport { NavLink, Route, Switch } from 'react-router-dom';\r\nimport { Button, Pane, Heading } from 'evergreen-ui';\r\nimport { useTranslation } from 'react-i18next';\r\n\r\nimport Footer from './layout/Footer';\r\nimport Header from './layout/Header';\r\nimport Signin from './Signin';\r\n\r\nimport './home.less';\r\nimport headerLogo from '../assets/ssh.png';\r\n\r\nconst MainPage = () => {\r\n const { t } = useTranslation(['home']);\r\n return (\r\n <>\r\n <Pane\r\n alignItems=\"center\"\r\n justifyContent=\"center\"\r\n display=\"flex\"\r\n flexDirection=\"column\">\r\n <div\r\n style={{\r\n minHeight: '360px',\r\n marginTop: '10rem',\r\n textAlign: 'center',\r\n }}>\r\n <img src={headerLogo} className=\"App-logo\" alt=\"logo\" />\r\n <Heading marginBottom=\"0.6rem\" marginTop=\"0.6rem\" size={700}>\r\n {t('home:welcome')}\r\n </Heading>\r\n <div>\r\n <NavLink to=\"/signin\" className=\"focus-ring-link\">\r\n <Button appearance=\"primary\"> {t('home:goto_signin')} </Button>\r\n </NavLink>\r\n </div>\r\n </div>\r\n </Pane>\r\n </>\r\n );\r\n};\r\n\r\nconst Home = () => {\r\n return (\r\n <div className=\"home-container\">\r\n <header className=\"home-content-header\">\r\n <Header />\r\n </header>\r\n <main className=\"home-content-main main-content-container\">\r\n <Switch>\r\n <Route exact path={`/`} component={MainPage} />\r\n <Route path={`/signin`} component={Signin} />\r\n </Switch>\r\n </main>\r\n {/*<footer className=\"home-content-footer\">\r\n <Footer />\r\n </footer>\r\n */}\r\n </div>\r\n );\r\n};\r\n\r\nexport default Home;\r\n","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\Console.tsx",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\config\\api_routers.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\libs\\terminal-resize.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\term\\term_theme.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\libs\\utils.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\libs\\sshwebsocket.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\libs\\string_format.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\Signin.tsx",["205"],"import React, { useState } from 'react';\r\nimport { RouteComponentProps } from 'react-router-dom';\r\nimport {\r\n //Button,\r\n Pane,\r\n Heading,\r\n //TextInputField,\r\n //GeolocationIcon,\r\n //FormField,\r\n toaster,\r\n} from 'evergreen-ui';\r\nimport { useTranslation } from 'react-i18next';\r\n\r\nimport Config from '../config/config';\r\nimport Utils from '../libs/utils';\r\nimport apiRouters from '../config/api_routers';\r\n\r\n{\r\n /*interface FieldState {\r\n isInvalid: boolean;\r\n validationMessage: string | undefined;\r\n value: string;\r\n}\r\n\r\nconst checkHostFormat = (host: string) => {\r\n if (!host || host === '') {\r\n return [false, '', 22];\r\n }\r\n const hostList = host.split(':');\r\n if (hostList.length === 1) {\r\n return [true, host, 22];\r\n }\r\n const ok =\r\n hostList.length === 2 &&\r\n hostList[1].length !== 0 &&\r\n !isNaN(Number(hostList[1]));\r\n if (ok) {\r\n return [true, hostList[0], parseInt(hostList[1])];\r\n } else {\r\n return [false, host, 22];\r\n }\r\n};*/\r\n}\r\nconst Signin = (props: RouteComponentProps) => {\r\n React.useEffect(() => {\r\n window.addEventListener('message', (event) => {\r\n if (event.origin !== process.env.REACT_APP_CLUSTER_URL) return;\r\n\r\n doSignin(event.data);\r\n });\r\n }, []);\r\n\r\n const { t } = useTranslation(['signin']);\r\n\r\n const doSignin = (data: Record<string, string>) => {\r\n console.log(data);\r\n Utils.axiosInstance\r\n .post(Utils.loadUrl(apiRouters.router.sign_in, null), {\r\n // _xsrf: Utils.base64Decode(xsrf.split(\"|\")[0]), // todo\r\n host: data.host,\r\n port: data.port,\r\n username: data.username,\r\n passwd: data.password,\r\n })\r\n .then((response) => {\r\n try {\r\n if (!response.data || response.data.has_error) {\r\n // self.$Loading.error();\r\n switch (response.data.message) {\r\n case 0:\r\n toaster.danger(t('signin:form_has_error'));\r\n break;\r\n case 1:\r\n toaster.danger(t('signin:form_error_passport'));\r\n break;\r\n case 2:\r\n toaster.danger(t('signin:form_error_ssh_login'));\r\n break;\r\n }\r\n } else {\r\n if (!response.data.addition) {\r\n // self.$Loading.error();\r\n toaster.danger(t('signin:form_error_remote_server'));\r\n } else {\r\n // self.$Loading.finish();\r\n toaster.success(t('signin:signin_success'));\r\n localStorage.setItem('user.host', data.host);\r\n localStorage.setItem('user.username', data.username);\r\n sessionStorage.setItem(\r\n Config.jwt.tokenName,\r\n response.data.addition,\r\n );\r\n\r\n props.history.push('/console');\r\n }\r\n }\r\n } catch (e) {\r\n // self.$Loading.error();\r\n toaster.danger(t('signin:form_error_ssh_login'));\r\n }\r\n })\r\n .catch((e: Error) => {\r\n // self.$Loading.error();\r\n toaster.danger(t('signin:form_error_ssh_login') + ': ' + e.message);\r\n });\r\n };\r\n\r\n return (\r\n <Pane\r\n alignItems=\"center\"\r\n justifyContent=\"center\"\r\n display=\"flex\"\r\n flexDirection=\"column\">\r\n <div\r\n style={{ minHeight: '360px', marginTop: '10rem', textAlign: 'center' }}>\r\n <Heading marginBottom=\"0.6rem\" marginTop=\"0.6rem\" size={700}>\r\n {t('signin:form_title')}\r\n </Heading>\r\n {/*\r\n <form\r\n onSubmit={(event: React.FormEvent<HTMLFormElement>) => {\r\n event.preventDefault();\r\n doSignin();\r\n return false;\r\n }}>\r\n <Pane textAlign=\"left\">\r\n <TextInputField\r\n value={hostField.value}\r\n isInvalid={hostField.isInvalid}\r\n validationMessage={hostField.validationMessage}\r\n label={t('signin:form_fullhost_label')}\r\n onChange={onHostChanged}\r\n onBlur={onHostChanged}\r\n placeholder={t('signin:form_fullhost_ph')}\r\n marginBottom=\"8px\"\r\n />\r\n <TextInputField\r\n value={unameField.value}\r\n isInvalid={unameField.isInvalid}\r\n label={t('signin:form_username_label')}\r\n onChange={onUsernameChanged}\r\n onBlur={onUsernameChanged}\r\n placeholder={t('signin:form_username_ph')}\r\n validationMessage={unameField.validationMessage}\r\n marginBottom=\"8px\"\r\n />\r\n <TextInputField\r\n label={t('signin:form_passwd_label')}\r\n type=\"password\"\r\n placeholder={t('signin:form_passwd_ph')}\r\n marginBottom=\"24px\"\r\n onChange={onPasswdChanged}\r\n onBlur={onPasswdChanged}\r\n onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>): void => {\r\n // 'keypress' event misbehaves on mobile so we track 'Enter' key via 'keydown' event\r\n if (event.key === 'Enter') {\r\n event.preventDefault();\r\n event.stopPropagation();\r\n doSignin();\r\n }\r\n }}\r\n />\r\n </Pane>\r\n <FormField>\r\n <Button\r\n isLoading={submitLoading}\r\n type=\"submit\"\r\n width=\"100%\"\r\n appearance=\"primary\"\r\n justifyContent=\"center\"\r\n intent=\"success\"\r\n iconBefore={GeolocationIcon}>\r\n {t('signin:form_submit_btn')}\r\n </Button>\r\n </FormField>\r\n </form>\r\n */}\r\n </div>\r\n </Pane>\r\n );\r\n};\r\n\r\nexport default Signin;\r\n","C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\FileTrans.tsx",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\layout\\Header.tsx",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\layout\\Footer.tsx",[],[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\term\\XTerm.tsx",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\files\\files_types.ts",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\PathNav.tsx",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\SftpUpload.tsx",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\files\\GridFileItem.tsx",[],"C:\\ProgramasGO\\ssh-web-console\\web\\src\\components\\files\\DownloadingFile.tsx",[],[],"C:\\ProgramasGO\\web-console\\web\\src\\index.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\serviceWorkerRegistration.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\reportWebVitals.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\config\\config.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\locales\\i18n.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\App.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\locales\\locales.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\Console.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\Home.tsx",["206"],"C:\\ProgramasGO\\web-console\\web\\src\\config\\api_routers.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\term\\term_theme.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\libs\\terminal-resize.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\libs\\utils.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\libs\\sshwebsocket.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\libs\\string_format.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\FileTrans.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\Signin.tsx",["207"],"C:\\ProgramasGO\\web-console\\web\\src\\components\\term\\XTerm.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\layout\\Header.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\files\\files_types.ts",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\SftpUpload.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\PathNav.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\files\\GridFileItem.tsx",[],"C:\\ProgramasGO\\web-console\\web\\src\\components\\files\\DownloadingFile.tsx",[],{"ruleId":"208","severity":1,"message":"209","line":6,"column":8,"nodeType":"210","messageId":"211","endLine":6,"endColumn":14},{"ruleId":"208","severity":1,"message":"212","line":1,"column":17,"nodeType":"210","messageId":"211","endLine":1,"endColumn":25},{"ruleId":"208","severity":1,"message":"209","line":6,"column":8,"nodeType":"210","messageId":"211","endLine":6,"endColumn":14},{"ruleId":"208","severity":1,"message":"212","line":1,"column":17,"nodeType":"210","messageId":"211","endLine":1,"endColumn":25},"@typescript-eslint/no-unused-vars","'Footer' is defined but never used.","Identifier","unusedVar","'useState' is defined but never used."]
\ No newline at end of file
env:
browser: true
es2020: true
extends:
- 'eslint:recommended'
- 'plugin:react/recommended'
- 'plugin:@typescript-eslint/recommended'
- 'plugin:prettier/recommended'
parser: '@typescript-eslint/parser'
parserOptions:
ecmaFeatures:
jsx: true
ecmaVersion: 11
sourceType: module
plugins:
- react
- '@typescript-eslint'
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
"prettier/prettier": ["error", {
"endOfLine":"auto"
}]
}
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.vscode/
.idea/
node_modules/
build/
.*ignore
.gitattributes
*.html
Dockerfile
*.conf
.npmrc
.env
*.png
*.ico
*.svg
*.xml
LICENCE
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"jsxBracketSameLine": true,
"useTabs": false,
"overrides": [
{
"files": ".prettierrc",
"options": { "parser": "json" }
}
]
}
MIT License
Copyright (c) 2017 genshen chu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# sshWebConsole
frontend code of genshen/ssh-web-console
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn start
```
### Compiles and minifies for production
```
yarn build
```
### Run your tests
```
yarn test
```
### Lints and fixes files
```
yarn run lint
```
### Run your unit tests
```
yarn run test:unit
```
const { override, addLessLoader } = require('customize-cra');
module.exports = override(
addLessLoader({
lessOptions: {
javascriptEnabled: true,
}
})
)
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "ssh-web-console",
"version": "0.1.1",
"description": "Connect to your linux machine via ssh in your browser.",
"author": "genshen",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/genshen/webConsole"
},
"dependencies": {
"@rottitime/react-hook-message-event": "^1.0.8",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^16.9.53",
"@types/react-dom": "^16.9.8",
"axios": "^0.21.1",
"evergreen-ui": "^6.4.0",
"file-saver": "^2.0.5",
"i18next": "^19.8.4",
"js-base64": "2.5.1",
"react-dropzone": "^11.2.4",
"react-i18next": "^11.7.4",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.1",
"typescript": "^4.0.3",
"web-vitals": "^0.2.4",
"workbox-background-sync": "^5.1.3",
"workbox-broadcast-update": "^5.1.3",
"workbox-cacheable-response": "^5.1.3",
"workbox-core": "^5.1.3",
"workbox-expiration": "^5.1.3",
"workbox-google-analytics": "^5.1.3",
"workbox-navigation-preload": "^5.1.3",
"workbox-precaching": "^5.1.3",
"workbox-range-requests": "^5.1.3",
"workbox-routing": "^5.1.3",
"workbox-strategies": "^5.1.3",
"workbox-streams": "^5.1.3",
"xterm": "^4.3.0",
"xterm-addon-fit": "^0.4.0",
"xterm-addon-web-links": "^0.4.0"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject",
"format": "prettier --config .prettierrc --write 'src/*'"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.**/*": "prettier --write"
},
"devDependencies": {
"@babel/core": "^7.13.0",
"@types/file-saver": "^2.0.1",
"@types/js-base64": "^3.0.0",
"@types/react-router-dom": "^5.1.6",
"@typescript-eslint/eslint-plugin": "^4.6.1",
"@typescript-eslint/parser": "^4.6.1",
"babel-plugin-import": "^1.13.1",
"customize-cra": "^1.0.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.5",
"husky": "^4.3.6",
"less": "^3.12.2",
"less-loader": "^7.1.0",
"lint-staged": "^10.5.1",
"prettier": "^2.2.1",
"react": "^17.0.2",
"react-app-rewired": "^2.1.6",
"react-dom": "^17.0.2"
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2d8cf0" />
<meta name="description" content="ssh web console" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/static/img/icons/apple-touch-icon-180x180.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>SSH Web Console</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
{
"name": "ssh-web-console",
"short_name": "ssh-web-console",
"icons": [
{
"src": "favicon.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "./static/img/icons/android-chrome-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "./static/img/icons/android-chrome-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "./index.html",
"display": "standalone",
"background_color": "#5cadff",
"theme_color": "#2d8cf0"
}
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import Console from './components/Console';
import Home from './components/Home';
function App() {
return (
<div className="App">
<Switch>
<Route path="/console" exact component={Console} />
<Route path="/" component={Home} />
</Switch>
</div>
);
}
export default App;
import React, { useEffect, useRef, useState } from 'react';
import {
Pane,
Text,
Heading,
Badge,
Menu,
Popover,
Position,
Avatar,
Portal,
Button,
toaster,
CornerDialog,
} from 'evergreen-ui';
import {
FullCircleIcon,
UngroupObjectsIcon,
RefreshIcon,
SwapVerticalIcon,
FullscreenIcon,
MinimizeIcon,
LogOutIcon,
CogIcon,
ErrorIcon,
DisableIcon,
} from 'evergreen-ui';
import { RouteComponentProps } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
import XTerm from './term/XTerm';
import theme from './term/term_theme';
import FileTrans, { NodeConfig, ConnStatus } from './FileTrans';
import sshWebSocket from '../libs/sshwebsocket';
import terminalResize from '../libs/terminal-resize';
import Util from '../libs/utils';
import apiRouters from '../config/api_routers';
import Config from '../config/config';
import stringFormat from '../libs/string_format';
import './console.less';
type ConnStatusProps = {
host: string;
status: ConnStatus;
};
const ConnectionStatus = (props: ConnStatusProps) => {
if (props.status === ConnStatus.Connecting) {
return (
<>
<UngroupObjectsIcon
verticalAlign="baseline"
size={10}
color="info"
marginRight={8}
/>
<Badge isInteractive textTransform="lowercase" color="blue">
waiting connection
</Badge>
</>
);
} else if (props.status === ConnStatus.ConnectionLost) {
return (
<>
<DisableIcon
verticalAlign="baseline"
size={10}
color="#FAE2E2"
marginRight={8}
/>
<Badge isInteractive color="red">
connection lost
</Badge>
</>
);
} else {
return (
<>
<FullCircleIcon
verticalAlign="baseline"
size={10}
color="success"
marginRight={8}
/>
<Badge isInteractive textTransform="lowercase" color="green">
{props.host}
</Badge>
</>
);
}
};
const Console = (props: RouteComponentProps) => {
const [isSideSheetShown, setSideSheetShwon] = useState<boolean>(false);
const terminalRef = useRef<XTerm>(null);
const { t } = useTranslation(['translation', 'console']);
const [fitAddon] = useState<FitAddon>(new FitAddon());
const [webLinksAddon] = useState<WebLinksAddon>(new WebLinksAddon());
const [fullscreen, setFullscreen] = useState<boolean>(false);
const [connecting, setConnecting] = useState<ConnStatus>(
ConnStatus.Connecting,
);
const [nodeConfig, setNodeConfig] = useState<NodeConfig>({
host: 'waiting connection',
username: 'Loading',
});
const [showCornerDialog, setShowCornerDialog] = useState<boolean>(false);
let ws: WebSocket | null = null;
useEffect(() => {
const lhost = window.localStorage.getItem('user.host');
const luname = window.localStorage.getItem('user.username');
if (lhost === null) {
return;
}
if (luname === null) {
return;
}
setNodeConfig({ host: lhost, username: luname });
}, []);
useEffect(() => {
// Once the terminal is loaded write a new line to it.
const term = terminalRef.current!.terminal;
fitAddon.fit();
term.writeln('Welcome to SSH web-console!');
const _t = sessionStorage.getItem(Config.jwt.tokenName);
if (_t === null) {
toaster.danger(t('console:web_socket_expire'));
// setConnecting(ConnStatus.ConnectionLost)
props.history.push('/signin');
return;
}
ws = new WebSocket(
Util.loadWebSocketUrl(
apiRouters.router.ws_ssh,
stringFormat.format(
apiRouters.params.ws_ssh,
term.cols + '',
term.rows + '',
_t,
),
),
);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
setConnecting(ConnStatus.ConnectionAlive);
};
ws.onclose = () => {
term.setOption('cursorBlink', false);
sessionStorage.removeItem(Config.jwt.tokenName);
setConnecting(ConnStatus.ConnectionLost);
setShowCornerDialog(true);
};
sshWebSocket.bindTerminal(term, ws!, true, -1);
terminalResize.bindTerminalResize(term, ws!);
return () => {
if (ws !== null) {
ws.close();
}
};
}, []);
useEffect(() => {
fitAddon.fit();
}, [fullscreen]);
const onWindowResize = () => {
fitAddon.fit();
};
const closeWindowListener = (ev: BeforeUnloadEvent) => {
ev.preventDefault();
ev.returnValue = t('console:make_sure_to_leave');
};
useEffect(() => {
window.addEventListener('resize', onWindowResize);
window.addEventListener('beforeunload', closeWindowListener);
return () => {
window.removeEventListener('resize', onWindowResize);
window.removeEventListener('beforeunload', closeWindowListener);
};
}, []);
return (
<Pane height="100vh" display="flex" flexDirection="column" borderRadius={3}>
<Pane
display="flex"
flexDirection="row"
alignItems="center"
background="rgba(27,33,47,0.86)">
<Heading padding={18} color="white">
{' '}
{t('title')}
</Heading>
<Pane
padding={18}
flex={1}
alignItems="center"
alignContent="center"
textAlign="center">
<ConnectionStatus status={connecting} host={nodeConfig.host} />
</Pane>
<Popover
position={Position.BOTTOM_LEFT}
content={
<Menu>
<Menu.Group>
<Menu.Item>
{'@'} {nodeConfig.username}{' '}
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
icon={LogOutIcon}
intent="danger"
onSelect={() => {
toaster.notify('under develop!', { id: 'dev' });
}}>
{t('console:nav_user_exit')}
</Menu.Item>
</Menu.Group>
</Menu>
}>
<Avatar
isSolid
name={nodeConfig.username}
size={36}
marginRight={36}
cursor="pointer"
/>
</Popover>
</Pane>
<Pane flex={1} overflowY="hidden">
<XTerm
className={
fullscreen ? 'term-container fullscreen' : 'term-container'
}
options={{
cursorBlink: true,
bellStyle: 'sound',
theme: theme.default_theme,
}}
addons={[fitAddon, webLinksAddon]}
ref={terminalRef}
/>
</Pane>
<Pane display="flex" alignItems="center">
<FileTrans
isShown={isSideSheetShown}
node={{ host: nodeConfig.host, username: nodeConfig.username }}
sshStatus={connecting}
hideSideSheeeet={() => {
setSideSheetShwon(false);
}}
/>
<Button intent="success" onClick={() => setSideSheetShwon(true)}>
SFTP
</Button>
<Button
intent="none"
marginLeft="0.05rem"
onClick={() => {
toaster.notify('under develop!', { id: 'dev' });
}}>
Paste
</Button>
<Pane flex="1"></Pane>
<Text marginRight="0.4rem">active time: 0:00:00</Text>
</Pane>
<CornerDialog
title={
<Text size={500} color="danger" alignItems="center" display="flex">
<ErrorIcon marginRight="0.2rem" />{' '}
{t('console:ssh_disconn_dialog_title')}
</Text>
}
isShown={showCornerDialog}
hasClose={false}
cancelLabel={t('console:ssh_disconn_dialog_cancel_btn')}
confirmLabel={t('console:ssh_disconn_dialog_confirm_btn')}
onConfirm={() => {
props.history.push('/signin');
}}
containerProps={{ zIndex: 20 }}
onCloseComplete={() => setShowCornerDialog(false)}>
{t('console:ssh_disconn_dialog_text')}
</CornerDialog>
<Portal>
<Pane
className="toolbar"
zIndex={10}
borderRadius={4}
display="flex"
flexDirection="column"
background="#2d8cf0"
padding={0}
position="fixed"
top={120}
right={64}>
<Button
appearance="minimal"
marginY={8}
className="toolbar-item"
onClick={() => {
toaster.notify('under develop!', { id: 'dev' });
}}>
<RefreshIcon color="white" size={10} />
</Button>
<Button
appearance="minimal"
marginY={8}
className="toolbar-item"
onClick={() => setSideSheetShwon(true)}>
<SwapVerticalIcon color="white" size={10} />
</Button>
<Button
appearance="minimal"
marginY={8}
className="toolbar-item"
onClick={() => {
setFullscreen(!fullscreen);
}}>
{!fullscreen && <FullscreenIcon color="white" size={10} />}
{fullscreen && <MinimizeIcon color="white" size={10} />}
</Button>
<Button
appearance="minimal"
marginY={8}
className="toolbar-item"
onClick={() => {
toaster.notify('under develop!', { id: 'dev' });
}}>
<CogIcon color="white" size={10} />
</Button>
</Pane>
</Portal>
</Pane>
);
};
export default Console;
import React, { useState } from 'react';
import {
Alert,
Button,
IconButton,
Card,
Heading,
Menu,
Pane,
Paragraph,
Popover,
Position,
SideSheet,
toaster,
} from 'evergreen-ui';
import { CogIcon, EyeOnIcon, EyeOffIcon } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import Config from '../config/config';
import Utils from '../libs/utils';
import apiRouters from '../config/api_routers';
import stringFormat from '../libs/string_format';
import PathNav, { SPLIT_CHAR } from './PathNav';
import SftpUpload, { UploadEvent, UploadStatus } from './SftpUpload';
import './file_trans.less';
import GriFileItem, {
DownloadEvent,
DownloadStatus,
} from './files/GridFileItem';
import {
FileItem,
FileModeEmpty,
FileModeIsDir,
FileModeNormalFile,
IsDir,
CurrentPath,
} from './files/files_types';
const HOME = 'HOME';
export enum ConnStatus {
Connecting = 1,
ConnectionAlive,
ConnectionLost,
}
export interface NodeConfig {
host: string;
username: string;
}
interface SideSftpProps {
isShown: boolean;
node: NodeConfig;
sshStatus: ConnStatus;
hideSideSheeeet: () => void;
}
const DefaultFileList: Array<FileItem> = [
{ name: HOME, path: '', mode: FileModeIsDir, loading: false },
];
interface GridFileViewProps {
sftpConnId: string;
fileList: Array<FileItem>;
currentPath: CurrentPath;
onPathChanged: (item: FileItem[], path: string, is_abs_path: boolean) => void;
uploadEvent: UploadEvent;
uploadStatus: UploadStatus;
dlStatus: DownloadStatus;
dlEvent: DownloadEvent;
}
const GridFileView = ({
sftpConnId,
fileList,
currentPath,
onPathChanged,
uploadEvent,
uploadStatus,
dlEvent,
dlStatus,
}: GridFileViewProps) => {
const { t } = useTranslation(['console', 'files']);
const strSettings = window.localStorage.getItem('show-hidden-files');
const [showHiddenFile, setShowHiddenFiles] = useState<boolean>(
strSettings === 'on' ? true : false,
);
const toggleShowHiddenFile = () => {
window.localStorage.setItem(
'show-hidden-files',
!showHiddenFile ? 'on' : 'off',
);
setShowHiddenFiles(!showHiddenFile);
};
const onPath = (path: string) => {
// when the path is changed // todo add cache
if (path && path === currentPath.current_path) {
return; // if it is the same path.
}
if (uploadStatus.isUploading) {
// if it is uploading.
toaster.danger(t('console:file_transfer.wait_for_unload_finish'));
return;
}
const item: FileItem = {
name: '',
mode: FileModeIsDir,
path: path,
loading: false,
};
lsCmd(
sftpConnId,
false,
item,
(path: string) => {
toaster.danger(t('console:file_transfer.error_while_ls') + path);
},
(children: FileItem[], error: boolean) => {
if (!error) {
onPathChanged(children, path, path.startsWith(SPLIT_CHAR));
}
},
);
};
return (
<Card
backgroundColor="white"
elevation={0}
minHeight="calc(100% - 32px)"
display="flex"
flexDirection="column"
padding="8px"
margin={16}>
<Pane
marginBottom="0.4rem"
className="path-nav"
display="flex"
flexDirection="row">
<div style={{ flex: 1 }}>
<PathNav path={currentPath.current_path} onPathClick={onPath} />
</div>
<Popover
position={Position.BOTTOM_LEFT}
content={
<Menu>
<Menu.Group>
<Menu.Item
icon={showHiddenFile ? EyeOffIcon : EyeOnIcon}
onSelect={toggleShowHiddenFile}>
{/* secondaryText={<>⌘R</>} */}
{showHiddenFile
? t('files:hide_hidden_files_menu')
: t('files:show_hidden_files_menu')}
</Menu.Item>
</Menu.Group>
</Menu>
}>
<IconButton appearance="minimal" icon={CogIcon} iconSize={18} />
</Popover>
</Pane>
<div className="overview-group-items">
{fileList
.sort((a, b) => {
if (IsDir(a) && !IsDir(b)) {
return -1;
}
if (!IsDir(a) && IsDir(b)) {
return 1;
}
return a.name == b.name ? 0 : a.name > b.name ? 1 : -1;
})
.map((f: FileItem) => {
if (!showHiddenFile && f.name.startsWith('.')) {
return null;
}
return (
<GriFileItem
key={f.name}
f={f}
currentPath={currentPath}
dlEvent={dlEvent}
dlStatus={dlStatus}
onPathChange={onPath}
sftpConnId={sftpConnId}
/>
);
})}
<SftpUpload
eventHandle={uploadEvent}
uploadStatus={uploadStatus}
cid={sftpConnId}
current_path={currentPath.current_path}
/>
</div>
</Card>
);
};
// dir_only means only to show directory
const lsCmd = (
sftpConnId: string,
dir_only: boolean,
item: FileItem,
ls_error: (path: string) => void,
callback: (items: FileItem[], error: boolean) => void,
) => {
// item.is_dir === true.
const path = item.path;
const _t = sessionStorage.getItem(Config.jwt.tokenName);
if (_t) {
Utils.axiosInstance
.get(
Utils.loadUrl(
apiRouters.router.sftp_ls,
stringFormat.format(
apiRouters.params.sftp_ls,
_t,
sftpConnId,
dir_only + '',
path,
),
),
{},
)
.then((response) => {
try {
if (!response.data || response.data.has_error) {
ls_error(path);
// todo dir not shown
const d: Array<FileItem> = [];
callback(d, true);
} else {
const messages = response.data.message;
const children: Array<FileItem> = [];
// eslint-disable-next-line
messages.forEach((ele: any) => {
if (dir_only && !ele.is_dir) {
return;
}
children.push({
name: ele.name,
mode: ele.mode,
path: ele.path,
loading: false,
children: [],
});
});
callback(children, false); // only callback here todo handle error for loading.
}
} catch (e) {
ls_error(path);
const d: Array<FileItem> = [];
callback(d, true);
}
});
}
};
// Here to open sftp connection and manager its status
const FileTrans = ({
isShown,
node,
sshStatus,
hideSideSheeeet,
}: SideSftpProps) => {
const [isSftpActive, setSftpActive] = useState<boolean>(false);
const [sftpConnLoading, setSftpConnLoading] = useState<boolean>(false);
const [sftpConnId, setSftpConnId] = useState<string>('');
const [fileList, setFileList] = useState<Array<FileItem>>(DefaultFileList);
const [currentPath, setCurentPath] = useState<CurrentPath>({
current_path: '',
display_path: HOME,
});
const [uploadStatus, setUploadStatus] = useState<UploadStatus>({
isUploading: false,
hasError: false,
percent: 0,
});
const [dlStatus, setDlStatus] = useState<DownloadStatus>({
isDownloading: false,
loaded: 0,
total: 0,
path: '',
filename: '',
});
const { t } = useTranslation(['console']);
// fixme: close websocket when isSftpActive changed to false
// eslint-disable-next-line
const ls = (
item: FileItem,
// eslint-disable-next-line
callback: (items: FileItem[], error: boolean) => void,
) => {
lsCmd(
sftpConnId,
true,
item,
(path: string) => {
toaster.danger(t('console:file_transfer.error_while_ls') + path);
},
(children: FileItem[], error: boolean) => {
// todo empty-text
if (!error && children.length === 0) {
// remove expand icon.
children.push({
name: '[empty]',
mode: FileModeEmpty,
path: '',
loading: false,
});
}
},
);
};
const setGridByFilePath = (
children: Array<FileItem>,
path: string,
is_abs_path: boolean,
) => {
setFileList(() => {
return [...children];
}); // todo add time, data?
if (is_abs_path) {
setCurentPath({ current_path: path, display_path: path });
} else {
setCurentPath({
current_path: path,
display_path: '$' + HOME + '/' + path,
});
}
};
const openSftpConnection = () => {
const _t = sessionStorage.getItem(Config.jwt.tokenName);
if (_t === null) {
toaster.danger(t('console:web_socket_expire'));
return;
}
setSftpConnLoading(true);
const socket = new WebSocket(
Utils.loadWebSocketUrl(
apiRouters.router.ws_sftp,
stringFormat.format(apiRouters.params.ws_sftp, _t),
),
);
// this.sftpSocket = socket; //todo
setSftpConnLoading(false);
setSftpActive(true);
socket.onmessage = (event) => {
// bind webSocket message event
const message = JSON.parse(event.data);
switch (message.type) {
case apiRouters.CID:
setSftpConnId(message.data);
break;
}
};
const heartBeatTimer = setInterval(() => {
socket.send(JSON.stringify({ type: 'heartbeat', data: '' }));
}, 20 * 1000);
// bind webSocket close event
socket.onclose = () => {
setSftpActive(false);
clearInterval(heartBeatTimer); // cancel heartbeat package sending.
};
};
const handleFileUploading = {
onUploadSuccess: (filename: string) => {
const item = {
path: currentPath + SPLIT_CHAR + filename,
name: filename,
mode: FileModeNormalFile,
loading: false,
children: [],
};
setUploadStatus({ isUploading: false, hasError: false, percent: 0 });
setFileList(() => {
return [...fileList, item];
});
toaster.success('Upload success!');
},
onUploadStart: () => {
setUploadStatus({ isUploading: true, hasError: false, percent: 0 });
},
onUploadProgress: (percent: number) => {
setUploadStatus({ isUploading: true, hasError: false, percent: percent });
console.log(percent);
},
onUploadError: () => {
setUploadStatus({ isUploading: false, hasError: true, percent: 0 });
toaster.danger('File upload error');
},
};
const handleFileDownload = {
onDownloadStart: (path: string, filename: string) => {
setDlStatus({
isDownloading: true,
loaded: 0,
total: 0,
path: path,
filename: filename,
});
},
onDownloadProgress: (
loaded: number,
total: number,
path: string,
filename: string,
) => {
setDlStatus({
isDownloading: true,
loaded: loaded,
total: total,
path: path,
filename: filename,
});
},
onDownloadFinish: () => {
setDlStatus({
isDownloading: false,
loaded: 0,
total: 0,
path: '',
filename: '',
});
},
};
return (
<>
<SideSheet
isShown={isShown}
shouldCloseOnEscapePress
onCloseComplete={hideSideSheeeet}
containerProps={{
display: 'flex',
flex: '1',
flexDirection: 'column',
}}>
<Pane zIndex={1} flexShrink={0} elevation={0} backgroundColor="white">
<Pane padding={16} borderBottom="muted">
<Heading size={600}>SFTP</Heading>
<Paragraph size={400} color="muted">
{node.username}@{node.host}
</Paragraph>
</Pane>
</Pane>
{(sshStatus !== ConnStatus.ConnectionAlive || !isSftpActive) && (
<Pane flex="1" overflowY="scroll" background="tint1" padding={16}>
{sshStatus !== ConnStatus.ConnectionAlive && (
<Alert
intent="warning"
title={t('console:file_transfer.ssh_not_active')}
marginBottom={32}
/>
)}
{sshStatus === ConnStatus.ConnectionAlive && !isSftpActive && (
<Pane
backgroundColor="transparent"
elevation={0}
border="none"
boxShadow="none"
display="flex"
alignItems="center"
justifyContent="center">
<Button
isLoading={sftpConnLoading}
intent="success"
onClick={openSftpConnection}>
{t('console:file_transfer.connect_to_sftp_server')}
</Button>
</Pane>
)}
</Pane>
)}
{sshStatus === ConnStatus.ConnectionAlive && isSftpActive && (
// use margin(in GridFileView), instead padding due to browser compatibility on firefox.
<Pane flex="1" overflowY="scroll" background="tint1">
<GridFileView
key="grid_view"
uploadEvent={handleFileUploading}
uploadStatus={uploadStatus}
dlEvent={handleFileDownload}
dlStatus={dlStatus}
fileList={fileList}
sftpConnId={sftpConnId}
currentPath={currentPath}
onPathChanged={setGridByFilePath}
/>
</Pane>
)}
</SideSheet>
</>
);
};
export default FileTrans;
import React from 'react';
import { NavLink, Route, Switch } from 'react-router-dom';
import { Button, Pane, Heading } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import Footer from './layout/Footer';
import Header from './layout/Header';
import Signin from './Signin';
import './home.less';
import headerLogo from '../assets/ssh.png';
const MainPage = () => {
const { t } = useTranslation(['home']);
return (
<>
<Pane
alignItems="center"
justifyContent="center"
display="flex"
flexDirection="column">
<div
style={{
minHeight: '360px',
marginTop: '10rem',
textAlign: 'center',
}}>
<img src={headerLogo} className="App-logo" alt="logo" />
<Heading marginBottom="0.6rem" marginTop="0.6rem" size={700}>
{t('home:welcome')}
</Heading>
<div>
<NavLink to="/signin" className="focus-ring-link">
<Button appearance="primary"> {t('home:goto_signin')} </Button>
</NavLink>
</div>
</div>
</Pane>
</>
);
};
const Home = () => {
return (
<div className="home-container">
<header className="home-content-header">
<Header />
</header>
<main className="home-content-main main-content-container">
<Switch>
<Route exact path={`/`} component={MainPage} />
<Route path={`/signin`} component={Signin} />
</Switch>
</main>
{/*<footer className="home-content-footer">
<Footer />
</footer>
*/}
</div>
);
};
export default Home;
import React from 'react';
import { Button, HomeIcon, SlashIcon } from 'evergreen-ui';
export const SPLIT_CHAR = '/';
interface PathNavProps {
path: string;
onPathClick: (path: string) => void;
}
const PathNav = ({ path, onPathClick }: PathNavProps) => {
const is_abs_path = path.startsWith(SPLIT_CHAR);
const dirs = path.replace(/^\/+|\/+$/g, '').split(SPLIT_CHAR);
const onItemClicked = (i: number) => {
if (i >= dirs.length) {
return;
}
if (i == -1) {
if (is_abs_path) {
onPathClick(SPLIT_CHAR); // root dir
} else {
onPathClick('');
}
return;
}
const dirs_dp = dirs.slice();
dirs_dp.splice(i + 1); // remove left ones
let selected_path = dirs_dp.join(SPLIT_CHAR);
if (is_abs_path) {
selected_path = SPLIT_CHAR + selected_path;
}
onPathClick(selected_path);
};
return (
<>
{is_abs_path && (
<Button
onClick={() => onItemClicked(-1)}
height={24}
paddingLeft="4px"
paddingRight="4px"
appearance="minimal"
intent="none">
<SlashIcon />
</Button>
)}
{!is_abs_path && (
<>
<Button
onClick={() => onItemClicked(-1)}
height={24}
paddingLeft="4px"
paddingRight="4px"
appearance="minimal"
intent="none">
<HomeIcon />
</Button>
</>
)}
{dirs.map((dir, i) => {
return (
<>
<span style={{ fontSize: '12px' }}>{SPLIT_CHAR}</span>
<Button
onClick={() => onItemClicked(i)}
height={24}
paddingLeft="4px"
paddingRight="4px"
appearance="minimal"
intent="none">
{dir}
</Button>
</>
);
})}
</>
);
};
export default PathNav;
import React, { useMemo } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Strong,
Tooltip,
UploadIcon,
InfoSignIcon,
toaster,
DoubleChevronUpIcon,
} from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import axios from 'axios';
import Config from '../config/config';
import Utils from '../libs/utils';
import apiRouters from '../config/api_routers';
import stringFormat from '../libs/string_format';
import './sftp_upload.less';
import './file_trans.less';
const activeStyle = {
style: {
boxShadow: '0 0 0 2px #016cd1',
},
};
export type UploadEvent = {
onUploadSuccess: (filename: string) => void;
onUploadStart: () => void;
onUploadProgress: (percent: number) => void;
onUploadError: (e: Error) => void;
};
export type UploadStatus = {
isUploading: boolean;
percent: number;
hasError: boolean;
};
interface UploadProps {
cid: string;
current_path: string;
uploadStatus: UploadStatus;
eventHandle: UploadEvent;
}
const SftpUpload = ({
cid,
current_path,
eventHandle,
uploadStatus,
}: UploadProps) => {
const { t } = useTranslation(['files']);
const onDrop = (acceptedFiles: File[]) => {
const _t = sessionStorage.getItem(Config.jwt.tokenName);
if (!cid || cid === '') {
toaster.danger(t('files:error_upload_fail_no_conneton'));
return;
}
let targetUrl = '';
if (_t) {
targetUrl = Utils.loadUrl(
apiRouters.router.sftp_upload,
stringFormat.format(
apiRouters.params.sftp_upload,
_t,
cid,
current_path,
),
);
} else {
toaster.danger(t('files:error_upload_fail_target_url'));
return;
}
acceptedFiles.forEach((file) => {
const formData = new FormData();
formData.append('file', file, file.name);
const config = {
// eslint-disable-next-line
onUploadProgress: (progressEvent: any) => {
if (progressEvent.lengthComputable) {
const percentCompleted = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
);
eventHandle.onUploadProgress(percentCompleted);
}
},
};
eventHandle.onUploadStart();
axios
.post(targetUrl, formData, config)
.then(() => {
eventHandle.onUploadSuccess(file.name);
})
.catch((err) => {
eventHandle.onUploadError(err);
});
});
};
const { getRootProps, getInputProps, isDragActive, isFocused } = useDropzone({
onDrop,
});
const style = useMemo(
() => ({
...(isDragActive ? activeStyle : {}),
...(isFocused ? activeStyle : {}),
}),
[isDragActive],
);
if (uploadStatus.isUploading) {
// uploading status
return (
<a
className="overview-item overview-item-flex upload-diabled"
title={t('files:uploading') + ':' + uploadStatus.percent + '%'}>
<DoubleChevronUpIcon size={32} className="item-icon" />
{uploadStatus.percent < 100 && (
<Strong size={300} className="item-title">
{t('files:uploading')} {':'} {uploadStatus.percent}
{'%'}
</Strong>
)}
{uploadStatus.percent >= 100 && (
<Strong size={300} className="item-title">
{t('files:upload_completed')}
</Strong>
)}
</a>
);
}
return (
<a className="overview-item overview-item-flex" {...getRootProps(style)}>
<input {...getInputProps()} />
<UploadIcon size={32} className="item-icon" />
<Tooltip content={t('files:upload_tooltip')}>
<Strong size={300} className="item-title">
{t('files:upload_btn')}
<InfoSignIcon size={14} marginLeft="6px" verticalAlign="middle" />
</Strong>
</Tooltip>
</a>
);
};
export default SftpUpload;
import React, { useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import {
//Button,
Pane,
Heading,
//TextInputField,
//GeolocationIcon,
//FormField,
toaster,
} from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import Config from '../config/config';
import Utils from '../libs/utils';
import apiRouters from '../config/api_routers';
{
/*interface FieldState {
isInvalid: boolean;
validationMessage: string | undefined;
value: string;
}
const checkHostFormat = (host: string) => {
if (!host || host === '') {
return [false, '', 22];
}
const hostList = host.split(':');
if (hostList.length === 1) {
return [true, host, 22];
}
const ok =
hostList.length === 2 &&
hostList[1].length !== 0 &&
!isNaN(Number(hostList[1]));
if (ok) {
return [true, hostList[0], parseInt(hostList[1])];
} else {
return [false, host, 22];
}
};*/
}
const Signin = (props: RouteComponentProps) => {
React.useEffect(() => {
window.addEventListener('message', (event) => {
if (event.origin !== process.env.REACT_APP_CLUSTER_URL) return;
doSignin(event.data);
});
}, []);
const { t } = useTranslation(['signin']);
const doSignin = (data: Record<string, string>) => {
console.log(data);
Utils.axiosInstance
.post(Utils.loadUrl(apiRouters.router.sign_in, null), {
// _xsrf: Utils.base64Decode(xsrf.split("|")[0]), // todo
host: data.host,
port: data.port,
username: data.username,
passwd: data.password,
})
.then((response) => {
try {
if (!response.data || response.data.has_error) {
// self.$Loading.error();
switch (response.data.message) {
case 0:
toaster.danger(t('signin:form_has_error'));
break;
case 1:
toaster.danger(t('signin:form_error_passport'));
break;
case 2:
toaster.danger(t('signin:form_error_ssh_login'));
break;
}
} else {
if (!response.data.addition) {
// self.$Loading.error();
toaster.danger(t('signin:form_error_remote_server'));
} else {
// self.$Loading.finish();
toaster.success(t('signin:signin_success'));
localStorage.setItem('user.host', data.host);
localStorage.setItem('user.username', data.username);
sessionStorage.setItem(
Config.jwt.tokenName,
response.data.addition,
);
props.history.push('/console');
}
}
} catch (e) {
// self.$Loading.error();
toaster.danger(t('signin:form_error_ssh_login'));
}
})
.catch((e: Error) => {
// self.$Loading.error();
toaster.danger(t('signin:form_error_ssh_login') + ': ' + e.message);
});
};
return (
<Pane
alignItems="center"
justifyContent="center"
display="flex"
flexDirection="column">
<div
style={{ minHeight: '360px', marginTop: '10rem', textAlign: 'center' }}>
<Heading marginBottom="0.6rem" marginTop="0.6rem" size={700}>
{t('signin:form_title')}
</Heading>
{/*
<form
onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
doSignin();
return false;
}}>
<Pane textAlign="left">
<TextInputField
value={hostField.value}
isInvalid={hostField.isInvalid}
validationMessage={hostField.validationMessage}
label={t('signin:form_fullhost_label')}
onChange={onHostChanged}
onBlur={onHostChanged}
placeholder={t('signin:form_fullhost_ph')}
marginBottom="8px"
/>
<TextInputField
value={unameField.value}
isInvalid={unameField.isInvalid}
label={t('signin:form_username_label')}
onChange={onUsernameChanged}
onBlur={onUsernameChanged}
placeholder={t('signin:form_username_ph')}
validationMessage={unameField.validationMessage}
marginBottom="8px"
/>
<TextInputField
label={t('signin:form_passwd_label')}
type="password"
placeholder={t('signin:form_passwd_ph')}
marginBottom="24px"
onChange={onPasswdChanged}
onBlur={onPasswdChanged}
onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>): void => {
// 'keypress' event misbehaves on mobile so we track 'Enter' key via 'keydown' event
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
doSignin();
}
}}
/>
</Pane>
<FormField>
<Button
isLoading={submitLoading}
type="submit"
width="100%"
appearance="primary"
justifyContent="center"
intent="success"
iconBefore={GeolocationIcon}>
{t('signin:form_submit_btn')}
</Button>
</FormField>
</form>
*/}
</div>
</Pane>
);
};
export default Signin;
.term-container {
height: 100%;
background-color: #1b212f;
}
.term-container.fullscreen {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: auto;
height: auto;
z-index: 9;
}
.toolbar .toolbar-item {
margin-top: 0;
margin-bottom: 0;
}
.toolbar .toolbar-item:not(:first-child):not(:last-child) {
border-radius: 0;
}
.toolbar>.toolbar-item:first-child:not(:last-child) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.toolbar>.toolbar-item:last-child:not(:first-child) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.toolbar .toolbar-item:hover {
background-color: #57a3f3 !important;
border-color: #57a3f3 !important;
}
.toolbar .toolbar-item:active{
background-color: #2b85e4 !important;
border-color: #2b85e4 !important;
}
.toolbar .toolbar-item:focus {
background-color: #2d8cf0 !important;
border-color: #2d8cf0 !important;
}
.path-nav {
overflow-x: scroll;
}
.overview-item-flex {
display: flex;
flex-direction: column;
align-items: center;
align-content: center;
}
.overview-item {
background-color:#F9F9FB;
color: #425A70;
// margin: 4px;
padding: 4px;
border-radius: 2px;
cursor: pointer;
.item-title {
width: 4.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.item-icon {
color: rgba(5, 124, 235, 0.877);
// #2d8cf0
margin-bottom: 6px;
}
&:hover {
// margin: -4px;
// padding: 4px;
background-color: rgba(1, 108, 209, 0.079);
color: #1070CA;
}
&:focus-visible {
// margin: -4px;
// padding: 4px;
box-shadow: 0 0 0 2px rgba(1, 108, 209, 0.699);
}
&:active {
// margin: -4px;
// padding: 4px;
background-color: rgba(16, 112, 202, 0.14);
color: #DDEBF7;
box-shadow: 0 0 0 2px #016cd1;
}
}
.overview-group-items {
display: inline-grid;
grid-template-columns: repeat(auto-fill, 88px);
// see https://stackoverflow.com/q/62655660/10068476
// grid-template-rows: repeat(auto-fill, 64px);
grid-auto-rows: 72px;
row-gap: 8px;
column-gap: 8px;
}
.overview-item.item-dl-container {
display: grid;
}
.dl-file-item {
display: flex;
flex-direction: column;
align-items: center;
align-content: center;
grid-column-start: 0;
grid-column-end: 1;
grid-row-start: 0;
grid-row-end: 1;
.item-title {
color: rgba(66, 90, 112, 0.45);
width: 4.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.item-icon {
color: rgba(5, 124, 235, 0.277);
// #2d8cf0
margin-bottom: 6px;
}
}
.dl-file-cover {
color: #425A70;
display: flex;
flex-direction: column;
align-items: center;
align-content: center;
justify-content: center;
z-index: 24;
grid-column-start: 0;
grid-column-end: 1;
grid-row-start: 0;
grid-row-end: 1;
}
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, DocumentIcon, Pill, Strong } from 'evergreen-ui';
import { DownloadStatus } from './GridFileItem';
import { FileItem } from './files_types';
interface DownloadingFileProps {
status: DownloadStatus;
file: FileItem;
}
const DownloadingFile = ({ status, file }: DownloadingFileProps) => {
const { t } = useTranslation(['files']);
let dataSize = '0';
let dataUnit = 'B';
if (status.loaded < 1024) {
dataSize = status.loaded + '';
} else if (status.loaded < 1024 * 1024) {
dataUnit = 'KB';
dataSize = (status.loaded / 1024).toFixed(2);
} else if (status.loaded < 1024 * 1024 * 1024) {
dataUnit = 'MB';
dataSize = (status.loaded / 1024 / 1024).toFixed(2);
} else {
dataUnit = 'GB';
dataSize = (status.loaded / 1024 / 1024 / 1024).toFixed(2);
}
return (
<a className="overview-item item-dl-container" title={file.name}>
<a className="dl-file-cover">
<Badge color="blue" marginBottom="0.2rem">
{t('files:dl_text_preparing')}
</Badge>
<Pill color="blue">
{dataSize} {dataUnit}
</Pill>
</a>
<a className="dl-file-item">
<DocumentIcon size={32} className="item-icon" />
<Strong size={300} className="item-title">
{' '}
{file.name}{' '}
</Strong>
</a>
</a>
);
};
export default DownloadingFile;
import React from 'react';
import {
DocumentIcon,
DocumentOpenIcon,
FolderCloseIcon,
Strong,
toaster,
} from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import { saveAs } from 'file-saver';
import axios from 'axios';
import Config from '../../config/config';
import Utils from '../../libs/utils';
import apiRouters from '../../config/api_routers';
import stringFormat from '../../libs/string_format';
import { CurrentPath, FileItem, IsDir, IsSymbolLink } from './files_types';
import DownloadingFile from './DownloadingFile';
export type DownloadStatus = {
isDownloading: boolean;
loaded: number;
total: number;
filename: string;
path: string;
};
export type DownloadEvent = {
onDownloadStart: (path: string, filename: string) => void;
onDownloadProgress: (
loaded: number,
total: number,
path: string,
filename: string,
) => void;
onDownloadFinish: (path: string, filename: string) => void;
};
interface GridFileItemProps {
f: FileItem;
sftpConnId: string;
currentPath: CurrentPath;
onPathChange: (path: string) => void;
dlStatus: DownloadStatus;
dlEvent: DownloadEvent;
}
let timer: NodeJS.Timeout;
// GridFileItem display a file item in grid view of files.
// the file item can be directory, or normal file, or symbolic link.
const GridFileItem = ({
f,
sftpConnId,
currentPath,
onPathChange,
dlStatus,
dlEvent,
}: GridFileItemProps) => {
const { t } = useTranslation(['console', 'files']);
const onGridFileDoubleClicked = (fileItem: FileItem) => {
if (IsDir(fileItem) || IsSymbolLink(fileItem)) {
// todo: set loading
onPathChange(fileItem.path);
}
};
const getFileBlob = (
url: string,
// eslint-disable-next-line
onDlProgress: (progressEvent: any) => void,
) => {
return new Promise<ArrayBuffer>((resolve, reject) => {
axios({
method: 'get',
url,
responseType: 'arraybuffer',
onDownloadProgress: onDlProgress,
})
.then((data) => {
resolve(data.data);
})
.catch((error) => {
reject(error.toString());
});
});
};
const onGridItemClicked = (fileItem: FileItem) => {
if (!IsDir(fileItem)) {
const path = fileItem.path;
const _t = sessionStorage.getItem(Config.jwt.tokenName);
if (_t) {
const dlUrl = Utils.loadUrl(
apiRouters.router.sftp_dl,
stringFormat.format(apiRouters.params.sftp_dl, _t, sftpConnId, path),
);
dlEvent.onDownloadStart(currentPath.current_path, fileItem.name);
// eslint-disable-next-line
getFileBlob(dlUrl, (progressEvent: any) => {
// on download progress event
if (progressEvent.lengthComputable) {
dlEvent.onDownloadProgress(
progressEvent.loaded,
progressEvent.total,
currentPath.current_path,
fileItem.name,
);
} else {
dlEvent.onDownloadProgress(
progressEvent.loaded,
0,
currentPath.current_path,
fileItem.name,
);
}
})
.then((buffer: ArrayBuffer) => {
dlEvent.onDownloadFinish(currentPath.current_path, fileItem.name);
const blob = new Blob([buffer], {
type: 'application/octet-stream',
});
saveAs(blob, fileItem.name);
})
.catch(() => {
// download error
toaster.danger(t('files:dl_file_failed'));
dlEvent.onDownloadFinish(currentPath.current_path, fileItem.name);
});
}
}
};
if (IsDir(f)) {
return (
<a
className="overview-item overview-item-flex"
onDoubleClick={() => {
onGridFileDoubleClicked(f);
}}>
<FolderCloseIcon size={32} className="item-icon" />
<Strong size={300} title={f.name} className="item-title">
{f.name}
</Strong>
</a>
);
} else if (IsSymbolLink(f)) {
return (
<a
className="overview-item overview-item-flex"
onClick={(event) => {
clearTimeout(timer);
if (event.detail === 1) {
timer = setTimeout(() => {
onGridItemClicked(f);
}, 200);
} else if (event.detail === 2) {
onGridFileDoubleClicked(f);
}
}}>
<DocumentOpenIcon size={32} className="item-icon" />
<Strong size={300} title={f.name} className="item-title">
{f.name}
</Strong>
</a>
);
} else {
// file
if (
dlStatus.isDownloading &&
dlStatus.filename === f.name &&
dlStatus.path === currentPath.current_path
) {
return <DownloadingFile status={dlStatus} file={f} />;
}
return (
<a
className="overview-item overview-item-flex"
onClick={() => {
onGridItemClicked(f);
}}>
<DocumentIcon size={32} className="item-icon" />
<Strong size={300} title={f.name} className="item-title">
{f.name}
</Strong>
</a>
);
}
};
export default GridFileItem;
export type CurrentPath = {
current_path: string;
display_path: string;
};
export type FileItem = {
path: string;
name: string;
mode: number;
loading: boolean;
children?: Array<FileItem>;
};
export const FileModeEmpty = 0;
export const FileModeIsDir = 1 << (32 - 1);
export const FileModeSymbolLink = 1 << (32 - 5);
export const FileModeNormalFile = 2;
export const IsDir = (file: FileItem) => {
return (file.mode & FileModeIsDir) !== 0;
};
export const IsSymbolLink = (file: FileItem) => {
return (file.mode & FileModeSymbolLink) !== 0;
};
.focus-ring-link,
.focus-ring-link:hover,
.focus-ring-link:visited,
.focus-ring-link:active {
text-decoration: none;
color: #234361;
}
.home-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content-container,
.main-content-container-fluid {
padding-right: 15px;
padding-left: 15px;
margin-right: auto;
margin-left: auto;
}
@media (min-width: 768px) {
.main-content-container {
width: 750px;
}
}
@media (min-width: 992px) {
.main-content-container {
width: 970px;
}
}
@media (min-width: 1200px) {
.main-content-container {
width: 1170px;
}
}
.home-content-header {
}
.home-content-main {
flex-grow: 1;
padding-top: 8px;
}
.home-content-footer {
}
.App-logo {
height: 28vmin;
pointer-events: none;
}
import React from 'react';
import { Pane, Button, GitRepoIcon } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
import { SwitchLang } from '../../locales/i18n';
import './footer.less';
const githublink = 'https://github.com/genshen/ssh-web-console';
function Footer() {
const { t } = useTranslation(['translation']);
return (
<Pane
display="flex"
justifyContent="center"
alignItems="center"
padding={16}
background="tint2"
borderRadius={3}>
<Pane>
<Button
is="a"
target="_blank"
href={githublink}
marginRight={8}
iconBefore={GitRepoIcon}
appearance="minimal">
{' '}
Github{' '}
</Button>
<Button marginRight={8} onClick={SwitchLang}>
{' '}
{t('switch_lang')}{' '}
</Button>
</Pane>
</Pane>
);
}
export default Footer;
import React from 'react';
import { Pane, Heading } from 'evergreen-ui';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import './header.less';
const Header = () => {
const { t } = useTranslation(['translation']);
return (
<Pane
display="flex"
padding={16}
background="tint2"
border={false}
className="header-pane">
<Pane flex={1} alignItems="center" display="flex">
<Heading size={600}>
<Link to="/" className="focus-ring-link">
{t('title')}
</Link>
</Heading>
</Pane>
</Pane>
);
};
export default Header;
.PageFooter {
background-color: #234361;
}
.PageFooter-inner {
align-items: center;
display: flex;
margin-top: 48px;
margin-bottom: 48px;
}
.PageFooter-left {
flex: 1;
}
.PageFooter-right {
flex: 3;
text-align: right;
& p {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
}
& a {
color: rgba(255, 255, 255, 0.8);
border-bottom: 2px solid rgba(255, 255, 255, 0.2);
&:hover {
color: rgba(255, 255, 255, 1);
border-bottom: 2px solid rgba(255, 255, 255, 1);
}
}
}
\ No newline at end of file
.header-pane {
z-index: 9;
background: #F9F9FB;
box-sizing: border-box;
box-shadow: 0 4px 8px -2px rgba(0,0,0,.15);
}
.overview-item.upload-diabled {
cursor: progress;
}
// This file is copied from: https://github.com/robert-harbison/xterm-for-react
import * as React from 'react';
import PropTypes from 'prop-types';
import 'xterm/css/xterm.css';
// We are using these as types.
// eslint-disable-next-line no-unused-vars
import { Terminal, ITerminalOptions, ITerminalAddon } from 'xterm';
interface IProps {
/**
* Class name to add to the terminal container.
*/
className?: string;
/**
* Options to initialize the terminal with.
*/
options?: ITerminalOptions;
/**
* An array of XTerm addons to load along with the terminal.
*/
addons?: Array<ITerminalAddon>;
/**
* Adds an event listener for when a binary event fires. This is used to
* enable non UTF-8 conformant binary messages to be sent to the backend.
* Currently this is only used for a certain type of mouse reports that
* happen to be not UTF-8 compatible.
* The event value is a JS string, pass it to the underlying pty as
* binary data, e.g. `pty.write(Buffer.from(data, 'binary'))`.
*/
onBinary?(data: string): void;
/**
* Adds an event listener for the cursor moves.
*/
onCursorMove?(): void;
/**
* Adds an event listener for when a data event fires. This happens for
* example when the user types or pastes into the terminal. The event value
* is whatever `string` results, in a typical setup, this should be passed
* on to the backing pty.
*/
onData?(data: string): void;
/**
* Adds an event listener for when a key is pressed. The event value contains the
* string that will be sent in the data event as well as the DOM event that
* triggered it.
*/
onKey?(event: { key: string; domEvent: KeyboardEvent }): void;
/**
* Adds an event listener for when a line feed is added.
*/
onLineFeed?(): void;
/**
* Adds an event listener for when a scroll occurs. The event value is the
* new position of the viewport.
* @returns an `IDisposable` to stop listening.
*/
onScroll?(newPosition: number): void;
/**
* Adds an event listener for when a selection change occurs.
*/
onSelectionChange?(): void;
/**
* Adds an event listener for when rows are rendered. The event value
* contains the start row and end rows of the rendered area (ranges from `0`
* to `Terminal.rows - 1`).
*/
onRender?(event: { start: number; end: number }): void;
/**
* Adds an event listener for when the terminal is resized. The event value
* contains the new size.
*/
onResize?(event: { cols: number; rows: number }): void;
/**
* Adds an event listener for when an OSC 0 or OSC 2 title change occurs.
* The event value is the new title.
*/
onTitleChange?(newTitle: string): void;
/**
* Attaches a custom key event handler which is run before keys are
* processed, giving consumers of xterm.js ultimate control as to what keys
* should be processed by the terminal and what keys should not.
*
* @param event The custom KeyboardEvent handler to attach.
* This is a function that takes a KeyboardEvent, allowing consumers to stop
* propagation and/or prevent the default action. The function returns
* whether the event should be processed by xterm.js.
*/
customKeyEventHandler?(event: KeyboardEvent): boolean;
}
export default class Xterm extends React.Component<IProps> {
/**
* The ref for the containing element.
*/
terminalRef: React.RefObject<HTMLDivElement>;
/**
* XTerm.js Terminal object.
*/
terminal!: Terminal; // This is assigned in the setupTerminal() which is called from the constructor
static propTypes = {
className: PropTypes.string,
options: PropTypes.object,
addons: PropTypes.array,
onBinary: PropTypes.func,
onCursorMove: PropTypes.func,
onData: PropTypes.func,
onKey: PropTypes.func,
onLineFeed: PropTypes.func,
onScroll: PropTypes.func,
onSelectionChange: PropTypes.func,
onRender: PropTypes.func,
onResize: PropTypes.func,
onTitleChange: PropTypes.func,
customKeyEventHandler: PropTypes.func,
};
constructor(props: IProps) {
super(props);
this.terminalRef = React.createRef();
// Bind Methods
this.onData = this.onData.bind(this);
this.onCursorMove = this.onCursorMove.bind(this);
this.onKey = this.onKey.bind(this);
this.onBinary = this.onBinary.bind(this);
this.onLineFeed = this.onLineFeed.bind(this);
this.onScroll = this.onScroll.bind(this);
this.onSelectionChange = this.onSelectionChange.bind(this);
this.onRender = this.onRender.bind(this);
this.onResize = this.onResize.bind(this);
this.onTitleChange = this.onTitleChange.bind(this);
this.setupTerminal();
}
setupTerminal() {
// Setup the XTerm terminal.
this.terminal = new Terminal(this.props.options);
// Load addons if the prop exists.
if (this.props.addons) {
this.props.addons.forEach((addon) => {
this.terminal.loadAddon(addon);
});
}
// Create Listeners
this.terminal.onBinary(this.onBinary);
this.terminal.onCursorMove(this.onCursorMove);
this.terminal.onData(this.onData);
this.terminal.onKey(this.onKey);
this.terminal.onLineFeed(this.onLineFeed);
this.terminal.onScroll(this.onScroll);
this.terminal.onSelectionChange(this.onSelectionChange);
this.terminal.onRender(this.onRender);
this.terminal.onResize(this.onResize);
this.terminal.onTitleChange(this.onTitleChange);
// Add Custom Key Event Handler
if (this.props.customKeyEventHandler) {
this.terminal.attachCustomKeyEventHandler(
this.props.customKeyEventHandler,
);
}
}
componentDidMount() {
if (this.terminalRef.current) {
// Creates the terminal within the container element.
this.terminal.open(this.terminalRef.current);
}
}
componentWillUnmount() {
// When the component unmounts dispose of the terminal and all of its listeners.
this.terminal.dispose();
}
private onBinary(data: string) {
if (this.props.onBinary) this.props.onBinary(data);
}
private onCursorMove() {
if (this.props.onCursorMove) this.props.onCursorMove();
}
private onData(data: string) {
if (this.props.onData) this.props.onData(data);
}
private onKey(event: { key: string; domEvent: KeyboardEvent }) {
if (this.props.onKey) this.props.onKey(event);
}
private onLineFeed() {
if (this.props.onLineFeed) this.props.onLineFeed();
}
private onScroll(newPosition: number) {
if (this.props.onScroll) this.props.onScroll(newPosition);
}
private onSelectionChange() {
if (this.props.onSelectionChange) this.props.onSelectionChange();
}
private onRender(event: { start: number; end: number }) {
if (this.props.onRender) this.props.onRender(event);
}
private onResize(event: { cols: number; rows: number }) {
if (this.props.onResize) this.props.onResize(event);
}
private onTitleChange(newTitle: string) {
if (this.props.onTitleChange) this.props.onTitleChange(newTitle);
}
render() {
return <div className={this.props.className} ref={this.terminalRef} />;
}
}
const abcTheme = {
foreground: '#ffffff',
background: '#000000',
cursor: '#ffffff',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#000000',
brightBlack: '#808080',
red: '#e06c75',
brightRed: '#e06c75',
green: '#A4EFA1',
brightGreen: '#A4EFA1',
yellow: '#EDDC96',
brightYellow: '#EDDC96',
magenta: '#e39ef7',
brightMagenta: '#e39ef7',
blue: '#5fcbd8',
brightBlue: '#5fcbd8',
cyan: '#5fcbd8',
brightCyan: '#5fcbd8',
white: '#d0d0d0',
brightWhite: '#ffffff',
};
const defaultTheme = {
foreground: '#ffffff',
background: '#1b212f',
cursor: '#ffffff',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#000000',
brightBlack: '#808080',
red: '#ce2f2b',
brightRed: '#f44a47',
green: '#00b976',
brightGreen: '#05d289',
yellow: '#e0d500',
brightYellow: '#f4f628',
magenta: '#bd37bc',
brightMagenta: '#d86cd8',
blue: '#1d6fca',
brightBlue: '#358bed',
cyan: '#00a8cf',
brightCyan: '#19b8dd',
white: '#e5e5e5',
brightWhite: '#ffffff',
};
const theme = {
default_theme: defaultTheme,
abc_theme: abcTheme,
};
export default theme;
import config from './config';
const apiRouters = {
router: {
sign_in: '/api/signin',
ws_ssh: '/ws/ssh',
ws_sftp: '/ws/sftp',
sftp_dl: '/api/sftp/dl',
sftp_ls: '/api/sftp/ls',
sftp_upload: '/api/sftp/upload',
},
params: {
ws_ssh: 'cols={{0}}&rows={{1}}&' + config.jwt.tokenName + '={{2}}',
ws_sftp: config.jwt.tokenName + '={{0}}',
sftp_dl: config.jwt.tokenName + '={{0}}&cid={{1}}&path={{2}}',
sftp_ls:
config.jwt.tokenName + '={{0}}&cid={{1}}&dir_only={{2}}&path={{3}}',
sftp_upload: config.jwt.tokenName + '={{0}}&cid={{1}}&path={{2}}',
},
CID: 'cid',
};
export default apiRouters;
const config = {
net: {
protocol: 'http',
webSocketProtocol: 'ws://',
host: window.location.host,
isVPN: false,
midParams: '',
api_domain: process.env.REACT_APP_API_URL
? process.env.REACT_APP_API_URL
: window.location.host,
vpnHost: 'vpn3.ustb.edu.cn',
vpnParame: process.env.REACT_APP_API_URL
? ',DanaInfo=' + process.env.REACT_APP_API_URL + ',SSL' // todo port
: window.location.host,
},
jwt: {
tokenName: '_t',
},
router: {
basepath: process.env.REACT_APP_ROUTER_BASE
? process.env.REACT_APP_ROUTER_BASE
: '',
},
};
config.net.protocol = window.location.protocol + '//';
config.net.webSocketProtocol = process.env.REACT_APP_API_HTTPS
? 'wss://'
: process.env.NODE_ENV !== 'development' &&
window.location.protocol === 'https:'
? 'wss://'
: 'ws://'; // todo add config.
config.net.isVPN = (() => {
// if (config.env !== 'development' && window.location.host !== config.net.api_domain) {
// return true // url += config.net.vpnParame
// }
if (process.env.NODE_ENV === 'production') {
return window.location.pathname.startsWith('/vpn');
}
return false;
})();
// get target host when communicating with backend api.
config.net.host = (() => {
if (config.net.isVPN) {
return config.net.vpnHost;
} else {
return config.net.api_domain;
}
})();
// please use midParams, instead of vpnParams.
config.net.midParams = (() => {
if (config.net.isVPN) {
return config.net.vpnParame;
} else {
return ''; // empty by default
}
})();
export default config;
declare module '@rottitime/react-hook-message-event';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import './index.less';
import App from './App';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import reportWebVitals from './reportWebVitals';
import config from './config/config';
import './locales/i18n';
ReactDOM.render(
<React.StrictMode>
<Router basename={config.router.basepath}>
<App />
</Router>
</React.StrictMode>,
document.getElementById('root'),
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://cra.link/PWA
serviceWorkerRegistration.register();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import { Base64 } from 'js-base64';
import { IDisposable, Terminal } from 'xterm';
const sshWebSocket = {
bindTerminal: (
term: Terminal,
websocket: WebSocket,
bidirectional: boolean,
bufferedTime: number,
) => {
// term.socket = websocket;
let messageBuffer = '';
const handleWebSocketMessage = function (ev: MessageEvent) {
if (ev.data instanceof ArrayBuffer) {
// it is Binary websocket data
term.write(new Uint8Array(ev.data));
return;
}
if (bufferedTime && bufferedTime > 0) {
if (messageBuffer) {
messageBuffer += ev.data;
} else {
messageBuffer = ev.data;
setTimeout(function () {
term.write(messageBuffer);
}, bufferedTime);
}
} else {
term.write(ev.data);
}
};
const handleTerminalData = function (data: string) {
websocket.send(
JSON.stringify({
type: 'terminal',
data: {
base64: Base64.encode(data), // encode data as base64 format
},
}),
);
};
websocket.onmessage = handleWebSocketMessage;
let dataListener: IDisposable | null = null;
if (bidirectional) {
dataListener = term.onData(handleTerminalData);
}
// send heartbeat package to avoid closing webSocket connection in some proxy environmental such as nginx.
const heartBeatTimer = setInterval(function () {
websocket.send(JSON.stringify({ type: 'heartbeat', data: '' }));
}, 20 * 1000);
websocket.addEventListener('close', function () {
websocket.removeEventListener('message', handleWebSocketMessage);
if (dataListener) {
dataListener.dispose();
}
// delete term.socket;
clearInterval(heartBeatTimer);
});
},
};
export default sshWebSocket;
const stringFormat = {
format: function (str: string, ...arr: Array<string>) {
// const a =
// typeof arr === "object"
// ? arr
// : Array.prototype.slice.call(arguments).slice(1);
return str.replace(/\{{([0-9]+)\}}/g, function (_, index) {
return arr[index];
});
},
};
export default stringFormat;
import { Terminal } from 'xterm';
interface TermSize {
rows: number;
cols: number;
}
const resize = {
bindTerminalResize: function (term: Terminal, websocket: WebSocket) {
const onTermResize = (size: TermSize) => {
websocket.send(
JSON.stringify({
type: 'resize',
data: { rows: size.rows, cols: size.cols },
}),
);
};
// register resize event.
const resizeListener = term.onResize(onTermResize);
// unregister resize event when WebSocket closed.
websocket.addEventListener('close', function () {
resizeListener.dispose();
});
},
};
export default resize;
import axios from 'axios';
import config from '../config/config';
const util = {
title: (title: string) => {
title = title ? title + ' - SSH Web Console' : 'SSh Web Console';
window.document.title = title;
},
// url: relative url starting with '/'
loadUrl: (url: string, params: string | null) => {
url = config.net.protocol + config.net.host + url + config.net.midParams;
return params ? url + '?' + params : url;
},
// url: relative url starting with '/'
loadWebSocketUrl: (url: string, params: string) => {
const protocol = config.net.webSocketProtocol;
url = protocol + config.net.host + url + config.net.midParams;
return params ? url + '?' + params : url;
},
// const ajaxUrl = config.env === 'development' ?
// 'http://127.0.0.1:80' :
// config.env === 'production' ?
// 'http://' + util.config.Domain:
// 'https://debug.url.com'; //todo
axiosInstance: axios.create({
timeout: 30000,
transformRequest: [
function (data) {
// Do whatever you want to transform the data
let ret = '';
for (const it in data) {
ret +=
encodeURIComponent(it) + '=' + encodeURIComponent(data[it]) + '&';
}
return ret;
},
],
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
};
export default util;
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import translation from './locales';
// the translations
// (tip move them in a JSON file and import them)
const resources = {
en: {
translation: translation['en-US'].global,
home: translation['en-US'].index,
console: translation['en-US'].console,
signin: translation['en-US'].signin,
files: translation['en-US'].files,
},
'zh-CN': {
translation: translation['zh-CN'].global,
home: translation['zh-CN'].index,
console: translation['zh-CN'].console,
signin: translation['zh-CN'].signin,
files: translation['zh-CN'].files,
},
};
const getLang = () => {
const navLang =
navigator.language === 'zh-CN' || navigator.language === 'en'
? navigator.language
: false;
const localLang = window.localStorage.getItem('language');
const lsLang =
localLang === 'zh-CN' || localLang === 'en' ? localLang : false;
return lsLang || navLang || 'en'; // fallback to en
};
i18n
.use(initReactI18next) // passes i18n down to react-i18next
// .use(LanguageDetector)
.init({
resources,
lng: getLang(),
fallbackLng: 'en',
// debug: true,
keySeparator: '.', // we can use keys in form messages.welcome
interpolation: {
escapeValue: false, // react already safes from xss
},
});
export const SwitchLang = () => {
const lang = getLang();
const newLang = lang === 'zh-CN' ? 'en' : 'zh-CN';
i18n.changeLanguage(newLang);
localStorage.setItem('language', newLang);
};
export default i18n;
export default {
'zh-CN': {
global: {
title: 'SSH Web Console',
home: '首页',
switch_lang: 'English',
error_occurs_try_refresh: 'Oh! 出现了一个错误,请刷新后重试.',
},
index: {
welcome: '欢迎使用 SSH Web Console',
goto_signin: '去往登录页面',
},
files: {
upload_btn: '上传文件',
uploading: '上传中',
upload_completed: '正在处理',
upload_tooltip: '点击或拖拽文件至此以上传',
show_hidden_files_menu: '显示隐藏文件',
hide_hidden_files_menu: '不显示隐藏文件',
error_upload_fail_no_conneton: '上传文件失败 (连接断开)',
error_upload_fail_target_url: '上传失败 (获取上传地址错误)',
dl_text_preparing: '准备中...',
dl_file_failed: '下载文件失败',
},
console: {
file_transfer: {
ssh_not_active: 'ssh未连接,请先连接ssh',
connect_to_sftp_server: '新建SFTP连接',
error_while_ls: '获取目录信息错误: ',
error_download_file: '下载文件出错: ',
empty_dir_or_no_sub_dir: '空目录或无子目录',
upload_status_pending: '正在处理',
upload_status_uploading: '正在上传 {percent}%',
upload_status_def: '上传',
upload_status_fail: '文件上传失败',
wait_for_unload_finish: '请等待当前上传文件结束',
},
nav_doc: '帮助文档',
nav_hpcer: 'HPCer',
nav_project: '项目',
nav_about: '关于',
nav_user_exit: '退出',
toolsbar_refresh: '刷新',
toolsbar_file_transfer: '文件传输',
toolsbar_fullscreen: '全屏',
toolsbar_settings: '设置',
toolsbar_exit_fullscreen: '退出全屏',
web_socket_disconnect: '已断开与远程主机连接',
web_socket_expire: '当前登录状态已失效,请重新登录',
ssh_disconn_dialog_title: '已断开与远程主机连接',
ssh_disconn_dialog_cancel_btn: '关闭',
ssh_disconn_dialog_confirm_btn: '重新连接',
ssh_disconn_dialog_text: 'SSH 连接已断开, 请重新登录连接',
relogin: '重新登录',
ecs_to_exit_fullscreen: '按 ESC 键以退出全屏模式',
modal_file_transfer_title: '文件传输',
modal_file_transfer_ok_btn: '完成',
make_sure_to_leave: '确定要离开吗?',
},
signin: {
form_title: '登录 SSH Web Console',
form_fullhost_label: '主机',
form_fullhost_ph: 'ip:端口号 (例: ssh.hpcer.dev:22)',
form_username_label: '用户名',
form_username_ph: '用户名',
form_passwd_label: '密码',
form_passwd_ph: '密码',
form_submit_btn: '登录',
form_submit_btn_loading: '正在登录',
form_fullhost_required: '主机地址不能为空',
form_fullhost_error: '主机地址不符合规范',
form_username_required: '用户名不能为空',
form_passwd_required: '密码不能为空',
form_has_error: '登录信息填写有误',
form_error_passport: '主机名或用户名或密码错误',
form_error_remote_server: '服务器连接错误',
form_error_ssh_login: '尝试ssh登录出错', // error test "who am i"
signin_success: '登录成功!',
},
},
'en-US': {
global: {
title: 'SSH Web Console',
home: 'Home',
switch_lang: '中文',
error_occurs_try_refresh: 'Oh! An error occurs.Please refresh the page.',
},
index: {
welcome: 'Welcome to SSH Web Console',
goto_signin: 'Go to Sign in Page',
},
files: {
upload_btn: 'Upload',
uploading: 'Uploading',
upload_completed: 'Processing',
show_hidden_files_menu: 'Show hidden files',
hide_hidden_files_menu: 'Not show hidden files',
upload_tooltip: 'Click or drag files here to upload',
error_upload_fail_no_conneton: 'Upload failed due to lost connection',
error_upload_fail_target_url:
'Upload failed while obtaining uploading address',
dl_text_preparing: 'Preparing',
dl_file_failed: 'Download file failded',
},
console: {
file_transfer: {
ssh_not_active: 'ssh is not active, please login in ssh first.',
connect_to_sftp_server: 'Start a new sftp connection',
error_while_ls: 'error happened while list directory: ',
error_download_file: 'error happened while download file: ',
empty_dir: 'Empty Directory or no Sub Directories',
upload_status_pending: 'pending',
upload_status_uploading: 'uploading {percent}%',
upload_status_def: 'Upload',
upload_status_fail: 'Upload Failed',
wait_for_unload_finish: 'Please wait for finishing uploading file(s)',
},
nav_doc: 'Docs',
nav_hpcer: 'HPCer',
nav_project: 'Projects',
nav_about: 'About',
nav_user_exit: 'Exit',
toolsbar_refresh: 'refresh',
toolsbar_file_transfer: 'file transfer',
toolsbar_fullscreen: 'fullscreen',
toolsbar_settings: 'settings',
toolsbar_exit_fullscreen: 'exit fullscreen',
web_socket_disconnect: 'Disconnected from remote host',
web_socket_expire: 'Your session has been expired,please relogin.',
ssh_disconn_dialog_title: 'Disconnected from remote host',
ssh_disconn_dialog_cancel_btn: 'Close',
ssh_disconn_dialog_confirm_btn: 'Reconnect',
ssh_disconn_dialog_text: 'SSH connection was lost, please reconnect',
relogin: 'Relogin',
ecs_to_exit_fullscreen: 'Press ESC key to exit fullscreen mode.',
modal_file_transfer_title: 'File Transfer',
modal_file_transfer_ok_btn: 'Finish',
make_sure_to_leave: 'Are sure to close this session?',
},
signin: {
form_title: 'Sign In SSH Web Console',
form_fullhost_label: 'Host',
form_fullhost_ph: 'ip:port (e.g: hpc.gensh.me:22)',
form_username_label: 'Username',
form_username_ph: 'Username',
form_passwd_label: 'Password',
form_passwd_ph: 'Password',
form_submit_btn: 'Sign In',
form_submit_btn_loading: 'Loading',
form_fullhost_required: 'host is required',
form_fullhost_error: 'host is not the right form',
form_username_required: 'username is required',
form_passwd_required: 'password is required',
form_has_error: 'There are some errors in the form,check again.',
form_error_passport: 'Error host or username or password',
form_error_remote_server: 'Error connecting to remote server',
form_error_ssh_login: 'Error to execute ssh login',
signin_success: 'success!',
},
},
};
/// <reference types="react-scripts" />
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
/// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
declare const self: ServiceWorkerGlobalScope;
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }: { request: Request; url: URL }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
}
// If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
}
// If this looks like a URL for a resource, because it contains
// a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
}
// Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
// Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
),
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA',
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://cra.link/PWA.',
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.',
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
module.exports = {
env: {
jest: true
}
};
import { shallowMount } from "@vue/test-utils";
import HelloWorld from "@/components/filetree/FileTree.vue";
describe("FileTree.vue", () => {
it("renders props.msg when passed", () => {
const msg = "new message";
const wrapper = shallowMount(HelloWorld, {
propsData: { msg }
});
expect(wrapper.text()).toMatch(msg);
});
});
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment