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
\ No newline at end of file
![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/genshen/ssh-web-console?logo=docker&sort=date)
![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
)
This diff is collapsed.
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
This diff is collapsed.
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 diff is collapsed.
{
"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;
This diff is collapsed.
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;
This diff is collapsed.
/// <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;
This diff is collapsed.
This diff is collapsed.
// 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 diff is collapsed.
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