├── src ├── models │ ├── init.go │ ├── sftp_websocket.go │ ├── signin_form.go │ └── ssh_websocket.go ├── controllers │ ├── files │ │ ├── file_stat_controller.go │ │ ├── fstp_mesage_dispatcher.go │ │ ├── download_controller.go │ │ ├── ls_controller.go │ │ ├── upload_controller.go │ │ └── establish_controller.go │ ├── websocker_copy.go │ ├── message_dispatcher.go │ ├── auth_base_controller.go │ ├── main_controller.go │ └── websocket.go ├── utils │ ├── http_utils.go │ ├── session.go │ ├── jwt_auth.go │ ├── config.go │ ├── sftp_utils.go │ ├── soft_static.go │ └── ssh_utils.go └── routers │ └── router.go ├── .gitmodules ├── Screenshots ├── shot2.png ├── shot3.png └── shot4.png ├── tests └── default_test.go ├── docker-compose.yml ├── go.mod ├── .gitignore ├── main.go ├── Makefile ├── Dockerfile ├── LICENSE ├── conf └── config.yaml ├── .github └── workflows │ └── build-and-release.yml ├── README.md └── go.sum /src/models/init.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func init() { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "web"] 2 | path = web 3 | url = https://github.com/genshen/webConsole.git 4 | -------------------------------------------------------------------------------- /Screenshots/shot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genshen/ssh-web-console/HEAD/Screenshots/shot2.png -------------------------------------------------------------------------------- /Screenshots/shot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genshen/ssh-web-console/HEAD/Screenshots/shot3.png -------------------------------------------------------------------------------- /Screenshots/shot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genshen/ssh-web-console/HEAD/Screenshots/shot4.png -------------------------------------------------------------------------------- /tests/default_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func init() { 8 | 9 | } 10 | 11 | // TestMain is a sample to run an endpoint test 12 | func TestMain(m *testing.M) { 13 | 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | ssh-web-console: 4 | image: genshen/ssh-web-console:latest 5 | restart: unless-stopped 6 | volumes: 7 | - "./conf:/home/web/conf" 8 | network_mode: host 9 | -------------------------------------------------------------------------------- /src/models/sftp_websocket.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | SftpWebSocketMessageTypeHeartbeat = "heartbeat" 5 | SftpWebSocketMessageTypeID = "cid" 6 | ) 7 | 8 | type SftpWebSocketMessage struct { 9 | Type string `json:"type"` 10 | Data interface{} `json:"data"` // json.RawMessage 11 | } 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/genshen/ssh-web-console 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 7 | github.com/oklog/ulid/v2 v2.0.2 8 | github.com/pkg/sftp v1.12.0 9 | github.com/rakyll/statik v0.1.7 10 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a 11 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 12 | nhooyr.io/websocket v1.8.6 13 | ) 14 | -------------------------------------------------------------------------------- /src/controllers/files/file_stat_controller.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/genshen/ssh-web-console/src/utils" 5 | "net/http" 6 | ) 7 | 8 | type FileStat struct{} 9 | 10 | func (f FileStat) ShouldClearSessionAfterExec() bool { 11 | return false 12 | } 13 | 14 | func (f FileStat) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session *utils.Session) { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | statik/ 3 | .DS_Store 4 | 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 18 | .glide/ 19 | 20 | *.exe~ 21 | static/ 22 | views/ 23 | .idea/ 24 | -------------------------------------------------------------------------------- /src/models/signin_form.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/genshen/ssh-web-console/src/utils" 4 | 5 | const ( 6 | SIGN_IN_FORM_TYPE_ERROR_VALID = iota 7 | SIGN_IN_FORM_TYPE_ERROR_PASSWORD 8 | SIGN_IN_FORM_TYPE_ERROR_TEST 9 | ) 10 | 11 | type UserInfo struct { 12 | utils.JwtConnection 13 | Username string `json:"username"` 14 | Password string `json:"-"` 15 | } 16 | 17 | type JsonResponse struct { 18 | HasError bool `json:"has_error"` 19 | Message interface{} `json:"message"` 20 | Addition interface{} `json:"addition"` 21 | } 22 | -------------------------------------------------------------------------------- /src/models/ssh_websocket.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | SSHWebSocketMessageTypeTerminal = "terminal" 5 | SSHWebSocketMessageTypeHeartbeat = "heartbeat" 6 | SSHWebSocketMessageTypeResize = "resize" 7 | ) 8 | 9 | type SSHWebSocketMessage struct { 10 | Type string `json:"type"` 11 | Data interface{} `json:"data"` // json.RawMessage 12 | } 13 | 14 | // normal terminal message 15 | type TerminalMessage struct { 16 | DataBase64 string `json:"base64"` 17 | } 18 | 19 | // terminal window resize 20 | type WindowResize struct { 21 | Cols int `json:"cols"` 22 | Rows int `json:"rows"` 23 | } 24 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/genshen/ssh-web-console/src/routers" 7 | "github.com/genshen/ssh-web-console/src/utils" 8 | "log" 9 | "net/http" 10 | ) 11 | 12 | var confFilePath string 13 | var version bool 14 | 15 | func init() { 16 | flag.StringVar(&confFilePath, "config", "conf/config.yaml", "filepath of config file.") 17 | flag.BoolVar(&version, "version", false, "show current version.") 18 | } 19 | 20 | func main() { 21 | flag.Parse() 22 | if version { 23 | fmt.Println("v0.4.0") 24 | return 25 | } 26 | if err := utils.InitConfig(confFilePath); err != nil { 27 | log.Fatal("config error,", err) 28 | return 29 | } 30 | routers.Register() 31 | log.Println("listening on port ", utils.Config.Site.ListenAddr) 32 | // listen http 33 | if err := http.ListenAndServe(utils.Config.Site.ListenAddr, nil); err != nil { 34 | log.Fatal(err) 35 | return 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/controllers/files/fstp_mesage_dispatcher.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/pkg/sftp" 5 | "os" 6 | "path" 7 | ) 8 | 9 | func DispatchSftpMessage(messageType int, message []byte, client *sftp.Client) error { 10 | var fullPath string 11 | if wd, err := client.Getwd(); err == nil { 12 | fullPath = path.Join(wd, "/tmp/") 13 | if _, err := client.Stat(fullPath); err != nil { 14 | if os.IsNotExist(err) { 15 | if err := client.Mkdir(fullPath); err != nil { 16 | return err 17 | } 18 | } else { 19 | return err 20 | } 21 | } 22 | } else { 23 | return err 24 | } 25 | 26 | //dstFile, err := client.Create(path.Join(fullPath, header.Filename)) 27 | //if err != nil { 28 | // return err 29 | //} 30 | //defer srcFile.Close() 31 | //defer dstFile.Close() 32 | // 33 | //_, err = dstFile.ReadFrom(srcFile) 34 | //if err != nil { 35 | // return err 36 | //} 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE=github.com/genshen/ssh-web-console 2 | 3 | .PHONY: clean all 4 | 5 | all: ssh-web-console-linux-amd64 ssh-web-console-linux-arm64 ssh-web-console-darwin-amd64 ssh-web-console-windows-amd64.exe 6 | 7 | ssh-web-console-linux-amd64: 8 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ssh-web-console-linux-amd64 ${PACKAGE} 9 | 10 | ssh-web-console-linux-arm64: 11 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o ssh-web-console-linux-arm64 ${PACKAGE} 12 | 13 | ssh-web-console-darwin-amd64: 14 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o ssh-web-console-darwin-amd64 ${PACKAGE} 15 | 16 | ssh-web-console-windows-amd64.exe: 17 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o ssh-web-console-windows-amd64.exe ${PACKAGE} 18 | 19 | ssh-web-console : 20 | go build -o ssh-web-console 21 | 22 | clean: 23 | rm -f ssh-web-console-linux-amd64 ssh-web-console-linux-arm64 ssh-web-console-darwin-amd64 ssh-web-console-windows-amd64.exe 24 | -------------------------------------------------------------------------------- /src/controllers/websocker_copy.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "nhooyr.io/websocket" 7 | "sync" 8 | ) 9 | 10 | // copy data from WebSocket to ssh server 11 | // and copy data from ssh server to WebSocket 12 | 13 | // write data to WebSocket 14 | // the data comes from ssh server. 15 | type WebSocketBufferWriter struct { 16 | buffer bytes.Buffer 17 | mu sync.Mutex 18 | } 19 | 20 | // implement Write interface to write bytes from ssh server into bytes.Buffer. 21 | func (w *WebSocketBufferWriter) Write(p []byte) (int, error) { 22 | w.mu.Lock() 23 | defer w.mu.Unlock() 24 | return w.buffer.Write(p) 25 | } 26 | 27 | // flush all data in this buff into WebSocket. 28 | func (w *WebSocketBufferWriter) Flush(ctx context.Context, messageType websocket.MessageType, ws *websocket.Conn) error { 29 | w.mu.Lock() 30 | defer w.mu.Unlock() 31 | if w.buffer.Len() != 0 { 32 | err := ws.Write(ctx, messageType, w.buffer.Bytes()) 33 | if err != nil { 34 | return err 35 | } 36 | w.buffer.Reset() 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/http_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "strconv" 8 | ) 9 | 10 | func ServeJSON(w http.ResponseWriter, j interface{}) { 11 | if j == nil { 12 | http.Error(w, "empty response data", 400) 13 | return 14 | } 15 | // w.Header().Set("Content-Type", "application/json") 16 | json.NewEncoder(w).Encode(j) 17 | // for request: json.NewDecoder(res.Body).Decode(&body) 18 | } 19 | 20 | func Abort(w http.ResponseWriter, message string, code int) { 21 | http.Error(w, message, code) 22 | } 23 | 24 | func GetQueryInt(r *http.Request, key string, defaultValue int) int { 25 | value, err := strconv.Atoi(r.URL.Query().Get(key)) 26 | if err != nil { 27 | log.Println("Error: get params cols error:", err) 28 | return defaultValue 29 | } 30 | return value 31 | } 32 | 33 | func GetQueryInt32(r *http.Request, key string, defaultValue uint32) uint32 { 34 | value, err := strconv.Atoi(r.URL.Query().Get(key)) 35 | if err != nil { 36 | log.Println("Error: get params cols error:", err) 37 | return defaultValue 38 | } 39 | return uint32(value) 40 | } 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build method: just run `docker build --rm --build-arg -t genshen/ssh-web-console .` 2 | 3 | # build frontend code 4 | FROM node:14.15.4-alpine3.12 AS frontend-builder 5 | 6 | COPY web web-console/ 7 | 8 | RUN cd web-console \ 9 | && yarn install \ 10 | && yarn build 11 | 12 | 13 | FROM golang:1.15.7-alpine3.13 AS builder 14 | 15 | # set to 'on' if using go module 16 | ARG STATIC_DIR=build 17 | 18 | 19 | RUN apk add --no-cache git \ 20 | && go get -u github.com/rakyll/statik 21 | 22 | COPY ./ /go/src/github.com/genshen/ssh-web-console/ 23 | COPY --from=frontend-builder web-console/build /go/src/github.com/genshen/ssh-web-console/${STATIC_DIR}/ 24 | 25 | RUN cd ./src/github.com/genshen/ssh-web-console/ \ 26 | && statik -src=${STATIC_DIR} \ 27 | && go build \ 28 | && go install 29 | 30 | ## copy binary 31 | FROM alpine:3.13 32 | 33 | ARG HOME="/home/web" 34 | 35 | RUN adduser -D web -h ${HOME} 36 | 37 | COPY --from=builder --chown=web /go/bin/ssh-web-console ${HOME}/ssh-web-console 38 | 39 | WORKDIR ${HOME} 40 | USER web 41 | 42 | VOLUME ["${HOME}/conf"] 43 | 44 | CMD ["./ssh-web-console"] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present genshen chu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /conf/config.yaml: -------------------------------------------------------------------------------- 1 | site: 2 | appname: ssh-web-console 3 | listen_addr: :2222 4 | runmode: prod 5 | deploy_host: console.hpc.gensh.me 6 | 7 | prod: 8 | # http path for static files and views 9 | static_prefix: / 10 | api_prefix: "" 11 | 12 | dev: # config used in debug mode. 13 | # http prefix for static files 14 | static_prefix: /static/ 15 | api_prefix: / 16 | # redirect static files requests to this address, redirect "static_prefix" to "static_redirect" 17 | # for example, static_prefix is "/static", static_redirect is "localhost:8080/dist", 18 | # this will redirect all requests having prefix "/static" to "localhost:8080/dist" 19 | static_redirect: "localhost:8080" 20 | static_dir: ./dist/ # if static_redirect is empty, http server will read static file from this dir. 21 | views_prefix: / # 22 | views_dir: views/ # views(html) directory. 23 | 24 | ssh: 25 | # io_mode: 1 # the mode reading data from ssh server: channel mode (0) OR session mode (1) 26 | 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) 27 | jwt: 28 | jwt_secret: secret.console.hpc.gensh.me 29 | token_lifetime: 7200 30 | issuer: issuer.ssh.gensh.me 31 | query_token_key: _t -------------------------------------------------------------------------------- /src/controllers/files/download_controller.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/genshen/ssh-web-console/src/utils" 5 | "io" 6 | "log" 7 | "net/http" 8 | "path" 9 | ) 10 | 11 | type Download struct{} 12 | 13 | func (d Download) ShouldClearSessionAfterExec() bool { 14 | return false 15 | } 16 | 17 | func (d Download) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) { 18 | cid := r.URL.Query().Get("cid") // get connection id. 19 | if client := utils.ForkSftpClient(cid); client == nil { 20 | utils.Abort(w, "error: lost sftp connection.", 400) 21 | log.Println("Error: lost sftp connection.") 22 | return 23 | } else { 24 | if wd, err := client.Getwd(); err == nil { 25 | relativePath := r.URL.Query().Get("path") // get path. 26 | fullPath := path.Join(wd, relativePath) 27 | if fileInfo, err := client.Stat(fullPath); err == nil && !fileInfo.IsDir() { 28 | if file, err := client.Open(fullPath); err == nil { 29 | defer file.Close() 30 | w.Header().Add("Content-Disposition", "attachment;filename="+fileInfo.Name()) 31 | w.Header().Add("Content-Type", "application/octet-stream") 32 | io.Copy(w, file) 33 | return 34 | } 35 | } 36 | } 37 | utils.Abort(w, "no such file", 400) 38 | return 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/controllers/message_dispatcher.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "github.com/genshen/ssh-web-console/src/models" 7 | "golang.org/x/crypto/ssh" 8 | "io" 9 | "nhooyr.io/websocket" 10 | ) 11 | 12 | func DispatchMessage(sshSession *ssh.Session, messageType websocket.MessageType, wsData []byte, wc io.WriteCloser) error { 13 | var socketData json.RawMessage 14 | socketStream := models.SSHWebSocketMessage{ 15 | Data: &socketData, 16 | } 17 | 18 | if err := json.Unmarshal(wsData, &socketStream); err != nil { 19 | return nil // skip error 20 | } 21 | 22 | switch socketStream.Type { 23 | case models.SSHWebSocketMessageTypeHeartbeat: 24 | return nil 25 | case models.SSHWebSocketMessageTypeResize: 26 | var resize models.WindowResize 27 | if err := json.Unmarshal(socketData, &resize); err != nil { 28 | return nil // skip error 29 | } 30 | sshSession.WindowChange(resize.Rows, resize.Cols) 31 | case models.SSHWebSocketMessageTypeTerminal: 32 | var message models.TerminalMessage 33 | if err := json.Unmarshal(socketData, &message); err != nil { 34 | return nil 35 | } 36 | if decodeBytes, err := base64.StdEncoding.DecodeString(message.DataBase64); err != nil { // todo ignore error 37 | return nil // skip error 38 | } else { 39 | if _, err := wc.Write(decodeBytes); err != nil { 40 | return err 41 | } 42 | } 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /src/controllers/files/ls_controller.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/genshen/ssh-web-console/src/models" 5 | "github.com/genshen/ssh-web-console/src/utils" 6 | "log" 7 | "net/http" 8 | "os" 9 | "path" 10 | ) 11 | 12 | type List struct{} 13 | type Ls struct { 14 | Name string `json:"name"` 15 | Path string `json:"path"` // including Name 16 | Mode os.FileMode `json:"mode"` // todo: use io/fs.FileMode 17 | } 18 | 19 | func (f List) ShouldClearSessionAfterExec() bool { 20 | return false 21 | } 22 | 23 | func (f List) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) { 24 | response := models.JsonResponse{HasError: true} 25 | cid := r.URL.Query().Get("cid") // get connection id. 26 | if client := utils.ForkSftpClient(cid); client == nil { 27 | utils.Abort(w, "error: lost sftp connection.", 400) 28 | log.Println("Error: lost sftp connection.") 29 | return 30 | } else { 31 | if wd, err := client.Getwd(); err == nil { 32 | relativePath := r.URL.Query().Get("path") // get path. 33 | fullPath := path.Join(wd, relativePath) 34 | if files, err := client.ReadDir(fullPath); err != nil { 35 | response.Addition = "no such path" 36 | } else { 37 | response.HasError = false 38 | fileList := make([]Ls, 0) // this will not be converted to null if slice is empty. 39 | for _, file := range files { 40 | fileList = append(fileList, Ls{Name: file.Name(), Mode: file.Mode(), Path: path.Join(relativePath, file.Name())}) 41 | } 42 | response.Message = fileList 43 | } 44 | } else { 45 | response.Addition = "no such path" 46 | } 47 | } 48 | utils.ServeJSON(w, response) 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/session.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | "sync" 6 | ) 7 | 8 | var SessionStorage SessionManager 9 | 10 | var mutex = new(sync.RWMutex) 11 | 12 | func init() { 13 | SessionStorage.new() 14 | } 15 | 16 | // use jwt string as session key, 17 | // store user information(username and password) in Session. 18 | type SessionManager struct { 19 | sessions map[string]Session 20 | } 21 | 22 | type Session struct { 23 | expire int64 24 | Value interface{} 25 | } 26 | 27 | func (s *Session) isExpired(timeNow int64) bool { 28 | if s.expire < timeNow { 29 | return true 30 | } 31 | return false 32 | } 33 | 34 | func (s *SessionManager) new() { 35 | s.sessions = make(map[string]Session) 36 | } 37 | 38 | /** 39 | * add a new session to session manager. 40 | * @params:token: token string 41 | * expire: unix time for expire 42 | * password: ssh user password 43 | */ 44 | func (s *SessionManager) Put(key string, expire int64, value interface{}) { 45 | s.gc() 46 | mutex.Lock() 47 | s.sessions[key] = Session{expire: expire, Value: value} 48 | mutex.Unlock() 49 | } 50 | 51 | func (s *SessionManager) Get(key string) (sessionData Session, exist bool) { 52 | mutex.RLock() 53 | defer mutex.RUnlock() 54 | session, ok := s.sessions[key] 55 | return session, ok 56 | } 57 | 58 | func (s *SessionManager) Delete(key string) { 59 | mutex.Lock() 60 | if _, ok := s.sessions[key]; ok { 61 | delete(s.sessions, key) 62 | } 63 | mutex.Unlock() 64 | } 65 | 66 | func (s *SessionManager) gc() { 67 | timeNow := time.Now().Unix() 68 | mutex.Lock() 69 | for key, session := range s.sessions { 70 | if session.isExpired(timeNow) { 71 | delete(s.sessions, key) 72 | } 73 | } 74 | mutex.Unlock() 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | # on: 2 | # push: 3 | # tags: 4 | # - 'v*' 5 | on: [push] 6 | 7 | name: Build&Release 8 | 9 | jobs: 10 | build: 11 | name: Build release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | with: 17 | submodules: recursive 18 | 19 | - name: Setup Go 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: 1.18 23 | 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: '16.x' 28 | 29 | - name: Build static 30 | run: cd web && yarn && yarn build && cd ../ 31 | 32 | - name: Get Go dependencies 33 | run: go mod download && go install github.com/rakyll/statik@v0.1.7 34 | 35 | - name: Static->GO generation 36 | run: statik --src=web/build 37 | 38 | - name: Build 39 | run: make 40 | - uses: actions/upload-artifact@v2 41 | with: 42 | name: build-artifact 43 | path: ssh-web-console-* 44 | 45 | release: 46 | name: On Release 47 | needs: build 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/download-artifact@v2 51 | with: 52 | name: build-artifact 53 | - run: ls -R 54 | 55 | - name: Release 56 | uses: softprops/action-gh-release@v1 57 | if: startsWith(github.ref, 'refs/tags/') 58 | with: 59 | files: | 60 | ssh-web-console-linux-amd64 61 | ssh-web-console-linux-arm64 62 | ssh-web-console-darwin-amd64 63 | ssh-web-console-windows-amd64.exe 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | -------------------------------------------------------------------------------- /src/utils/jwt_auth.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/dgrijalva/jwt-go" 7 | "time" 8 | ) 9 | 10 | // payload in jwt 11 | type JwtConnection struct { 12 | Host string 13 | Port int 14 | } 15 | 16 | type Claims struct { 17 | JwtConnection 18 | jwt.StandardClaims 19 | } 20 | 21 | // create a jwt token,and return this token as string type. 22 | // we can create a new token with Claims in it if login is successful. 23 | // then, we can known the host and port when setting up websocket or sftp. 24 | func JwtNewToken(connection JwtConnection, issuer string) (tokenString string, expire int64, err error) { 25 | expireToken := time.Now().Add(time.Second * time.Duration(Config.Jwt.TokenLifetime)).Unix() 26 | 27 | // We'll manually assign the claims but in production you'd insert values from a database 28 | claims := Claims{ 29 | JwtConnection: connection, 30 | StandardClaims: jwt.StandardClaims{ 31 | ExpiresAt: expireToken, 32 | Issuer: issuer, 33 | }, 34 | } 35 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 36 | 37 | // Signs the token with a secret. 38 | if signedToken, err := token.SignedString([]byte(Config.Jwt.Secret)); err != nil { 39 | return "", 0, err 40 | } else { 41 | return signedToken, expireToken, nil 42 | } 43 | } 44 | 45 | // Verify a jwt token 46 | func JwtVerify(tokenString string) (*Claims, error) { 47 | token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { 48 | // Make sure token's signature wasn't changed 49 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 50 | return nil, fmt.Errorf("Unexpected siging method") 51 | } 52 | return []byte(Config.Jwt.Secret), nil 53 | }) 54 | if err == nil { 55 | if claims, ok := token.Claims.(*Claims); ok && token.Valid { 56 | return claims, nil 57 | } 58 | } 59 | return nil, errors.New("unauthenticated") 60 | } 61 | -------------------------------------------------------------------------------- /src/controllers/auth_base_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/genshen/ssh-web-console/src/utils" 5 | "log" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | type AfterAuthenticated interface { 11 | // make sure token and session is not nil. 12 | ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, token *utils.Claims, session utils.Session) 13 | ShouldClearSessionAfterExec() bool 14 | } 15 | 16 | func AuthPreChecker(i AfterAuthenticated) func(w http.ResponseWriter, r *http.Request) { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | var authHead = r.Header.Get("Authorization") 19 | var token string 20 | if authHead != "" { 21 | lIndex := strings.LastIndex(authHead, " ") 22 | if lIndex < 0 || lIndex+1 >= len(authHead) { 23 | utils.Abort(w, "invalid token", 400) 24 | log.Println("Error: invalid token", 400) 25 | return 26 | } else { 27 | token = authHead[lIndex+1:] 28 | } 29 | } else { 30 | if token = r.URL.Query().Get(utils.Config.Jwt.QueryTokenKey); token == "" { 31 | utils.Abort(w, "invalid token", 400) 32 | log.Println("Error: invalid token", 400) 33 | return 34 | } // else token != "", then passed and go on running 35 | } 36 | 37 | if claims, err := utils.JwtVerify(token); err != nil { 38 | http.Error(w, "invalid token", 400) 39 | log.Println("Error: Cannot setup WebSocket connection:", err) 40 | } else { // check passed. 41 | // check session. 42 | if session, ok := utils.SessionStorage.Get(token); !ok { // make a session copy. 43 | utils.Abort(w, "Error: Cannot get Session data:", 400) 44 | log.Println("Error: Cannot get Session data for token", token) 45 | } else { 46 | if i.ShouldClearSessionAfterExec() { 47 | defer utils.SessionStorage.Delete(token) 48 | i.ServeAfterAuthenticated(w, r, claims, session) 49 | }else{ 50 | i.ServeAfterAuthenticated(w, r, claims, session) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-web-console 2 | 3 | you can connect to your linux machine by ssh in your browser. 4 | 5 | ![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/genshen/ssh-web-console?logo=docker&sort=date) 6 | ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/genshen/ssh-web-console?sort=semver&logo=docker) 7 | ![Docker Pulls](https://img.shields.io/docker/pulls/genshen/ssh-web-console?logo=docker) 8 | 9 | ## Quick start 10 | 11 | ```bash 12 | $ docker pull genshen/ssh-web-console:latest 13 | # docker build --build-arg GOMODULE=on -t genshen/ssh-web-console . # or build docker image on your own machine 14 | $ docker run -v ${PWD}/conf:/home/web/conf -p 2222:2222 --rm genshen/ssh-web-console 15 | ``` 16 | 17 | or using Docker Compose: 18 | 19 | ```bash 20 | $ git clone https://github.com/genshen/ssh-web-console.git 21 | $ cd ssh-web-console 22 | $ docker-compose up -d 23 | ``` 24 | 25 | Open your browser, visit `http://localhost:2222`. Enjoy it! 26 | 27 | **NOTE**: To run docker container, make sure config.yaml file is in directory ${PWD}/conf 28 | 29 | ## Build & Run 30 | 31 | Make sure your Go version is 1.11 or later 32 | 33 | ### Clone 34 | 35 | ```bash 36 | git clone --recurse-submodules https://github.com/genshen/ssh-web-console.git 37 | cd ssh-web-console 38 | ``` 39 | 40 | ### Build frontend 41 | 42 | ```bash 43 | cd web 44 | yarn install 45 | yarn build 46 | cd ../ 47 | ``` 48 | 49 | ### Build go 50 | 51 | ```bash 52 | go get github.com/rakyll/statik 53 | statik --src=web/build # use statik tool to convert files in 'web/build' dir to go code, and compile into binary. 54 | export GO111MODULE=on # for go 1.11.x 55 | go build 56 | ``` 57 | 58 | ## Run 59 | 60 | run: `./ssh-web-console`, and than you can enjoy it in your browser by visiting `http://localhost:2222`. 61 | 62 | ## Screenshots 63 | 64 | ![](./Screenshots/shot2.png) 65 | ![](./Screenshots/shot3.png) 66 | ![](./Screenshots/shot4.png) 67 | 68 | ## Related Works 69 | 70 | - [shibingli/webconsole](https://github.com/shibingli/webconsole) 71 | -------------------------------------------------------------------------------- /src/controllers/files/upload_controller.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/genshen/ssh-web-console/src/utils" 5 | "github.com/pkg/sftp" 6 | "log" 7 | "mime/multipart" 8 | "net/http" 9 | "path" 10 | ) 11 | 12 | type FileUpload struct{} 13 | 14 | func (f FileUpload) ShouldClearSessionAfterExec() bool { 15 | return false 16 | } 17 | 18 | func (f FileUpload) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) { 19 | cid := r.URL.Query().Get("cid") // get connection id. 20 | if sftpClient := utils.ForkSftpClient(cid); sftpClient == nil { 21 | utils.Abort(w, "error: lost sftp connection.", 400) 22 | log.Println("Error: lost sftp connection.") 23 | return 24 | } else { 25 | //file, header, err := this.GetFile("file") 26 | r.ParseMultipartForm(32 << 20) 27 | file, header, err := r.FormFile("file") 28 | relativePath := r.URL.Query().Get("path") // get path. default is "" 29 | if err != nil { 30 | log.Println("Error: getfile err ", err) 31 | utils.Abort(w, "error", 503) 32 | return 33 | } 34 | defer file.Close() 35 | 36 | if err := UploadFile(relativePath, sftpClient, file, header); err != nil { 37 | log.Println("Error: sftp error:", err) 38 | utils.Abort(w, "message", 503) 39 | } else { 40 | w.Write([]byte("success")) // todo write file name back. 41 | } 42 | } 43 | } 44 | 45 | // upload file to server via sftp. 46 | /** 47 | @desPath: relative path in remote server. 48 | */ 49 | func UploadFile(desPath string, client *sftp.Client, srcFile multipart.File, header *multipart.FileHeader) error { 50 | var fullPath string 51 | if wd, err := client.Getwd(); err == nil { 52 | fullPath = path.Join(wd, desPath) 53 | if _, err := client.Stat(fullPath); err != nil { 54 | return err // check path must exist 55 | } 56 | } else { 57 | return err 58 | } 59 | 60 | dstFile, err := client.Create(path.Join(fullPath, header.Filename)) 61 | if err != nil { 62 | return err 63 | } 64 | defer srcFile.Close() 65 | defer dstFile.Close() 66 | 67 | _, err = dstFile.ReadFrom(srcFile) 68 | if err != nil { 69 | return err 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/config.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | "io/ioutil" 6 | "os" 7 | ) 8 | 9 | var Config struct { 10 | Site struct { 11 | AppName string `yaml:"app_name"` 12 | RunMode string `yaml:"runmode"` 13 | DeployHost string `yaml:"deploy_host"` 14 | ListenAddr string `yaml:"listen_addr"` 15 | } `yaml:"site"` 16 | Prod struct { 17 | StaticPrefix string `yaml:"static_prefix"` // http prefix of static and views files 18 | ApiPrefix string `yaml:"api_prefix"` 19 | } `yaml:"prod"` 20 | Dev struct { 21 | StaticPrefix string `yaml:"static_prefix"` // https prefix of only static files 22 | //StaticPrefix string `yaml:"static_prefix"` // prefix of static files in dev mode. 23 | ApiPrefix string `yaml:"api_prefix"` 24 | 25 | // redirect static files requests to this address, redirect "StaticPrefix" to "StaticRedirect + StaticPrefix" 26 | // for example, StaticPrefix is "static", StaticRedirect is "localhost:8080/dist", 27 | // this will redirect all requests having prefix "static" to "localhost:8080/dist/static/" 28 | StaticRedirect string `yaml:"static_redirect"` 29 | // http server will read static file from this dir if StaticRedirect is empty 30 | StaticDir string `yaml:"static_dir"` 31 | ViewsPrefix string `yaml:"views_prefix"` // https prefix of only views files 32 | // path of view files (we can not redirect view files) to be served. 33 | ViewsDir string `yaml:"views_dir"` // todo 34 | } `yaml:"dev"` 35 | SSH struct { 36 | BufferCheckerCycleTime int `yaml:"buffer_checker_cycle_time"` 37 | } `yaml:"ssh"` 38 | Jwt struct { 39 | Secret string `yaml:"jwt_secret"` 40 | TokenLifetime int64 `yaml:"token_lifetime"` 41 | Issuer string `yaml:"issuer"` 42 | QueryTokenKey string `yaml:"query_token_key"` 43 | } `yaml:"jwt"` 44 | } 45 | 46 | func InitConfig(filepath string) error { 47 | f, err := os.Open(filepath) 48 | if err != nil { 49 | return err 50 | } 51 | defer f.Close() 52 | content, err := ioutil.ReadAll(f) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | err = yaml.Unmarshal(content, &Config) 58 | if err != nil { 59 | return err 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /src/controllers/main_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/genshen/ssh-web-console/src/models" 5 | "github.com/genshen/ssh-web-console/src/utils" 6 | "golang.org/x/crypto/ssh" 7 | "net/http" 8 | "strconv" 9 | ) 10 | 11 | func SignIn(w http.ResponseWriter, r *http.Request) { 12 | if r.Method != "POST" { 13 | http.Error(w, "Invalid request method.", 405) 14 | } else { 15 | var err error 16 | var errUnmarshal models.JsonResponse 17 | err = r.ParseForm() 18 | if err != nil { 19 | panic(err) 20 | } 21 | userinfo := models.UserInfo{} 22 | userinfo.Host = r.Form.Get("host") 23 | port := r.Form.Get("port") 24 | userinfo.Username = r.Form.Get("username") 25 | userinfo.Password = r.Form.Get("passwd") 26 | 27 | userinfo.Port, err = strconv.Atoi(port) 28 | if err != nil { 29 | userinfo.Port = 22 30 | } 31 | 32 | if userinfo.Host != "" && userinfo.Username != "" { 33 | //try to login session account 34 | session := utils.SSHShellSession{} 35 | session.Node = utils.NewSSHNode(userinfo.Host, userinfo.Port) 36 | err := session.Connect(userinfo.Username, ssh.Password(userinfo.Password)) 37 | if err != nil { 38 | errUnmarshal = models.JsonResponse{HasError: true, Message: models.SIGN_IN_FORM_TYPE_ERROR_PASSWORD} 39 | } else { 40 | defer session.Close() 41 | // create session 42 | client, err := session.GetClient() 43 | if err != nil { 44 | // bad connection. 45 | return 46 | } 47 | if session, err := client.NewSession(); err == nil { 48 | if err := session.Run("whoami"); err == nil { 49 | if token, expireUnix, err := utils.JwtNewToken(userinfo.JwtConnection, utils.Config.Jwt.Issuer); err == nil { 50 | errUnmarshal = models.JsonResponse{HasError: false, Addition: token} 51 | utils.ServeJSON(w, errUnmarshal) 52 | utils.SessionStorage.Put(token, expireUnix, userinfo) 53 | return 54 | } 55 | } 56 | } 57 | errUnmarshal = models.JsonResponse{HasError: true, Message: models.SIGN_IN_FORM_TYPE_ERROR_TEST} 58 | } 59 | } else { 60 | errUnmarshal = models.JsonResponse{HasError: true, Message: models.SIGN_IN_FORM_TYPE_ERROR_VALID} 61 | } 62 | utils.ServeJSON(w, errUnmarshal) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/controllers/files/establish_controller.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/genshen/ssh-web-console/src/models" 5 | "github.com/genshen/ssh-web-console/src/utils" 6 | "github.com/oklog/ulid/v2" 7 | "golang.org/x/crypto/ssh" 8 | "log" 9 | "math/rand" 10 | "net/http" 11 | "nhooyr.io/websocket" 12 | "nhooyr.io/websocket/wsjson" 13 | "time" 14 | ) 15 | 16 | type SftpEstablish struct{} 17 | 18 | func (e SftpEstablish) ShouldClearSessionAfterExec() bool { 19 | return false 20 | } 21 | 22 | // establish webSocket connection to browser to maintain connection with remote sftp server. 23 | // If establish success, add sftp connection to a list. 24 | // and then, handle all message from message (e.g.list files in one directory.). 25 | func (e SftpEstablish) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) { 26 | // init webSocket connection 27 | ws, err := websocket.Accept(w, r, nil) 28 | if err != nil { 29 | http.Error(w, "Cannot setup WebSocket connection:", 400) 30 | log.Println("Error: Cannot setup WebSocket connection:", err) 31 | return 32 | } 33 | defer ws.Close(websocket.StatusNormalClosure, "closed") 34 | 35 | // add sftp client to list if success. 36 | user := session.Value.(models.UserInfo) 37 | sftpEntity, err := utils.NewSftpEntity(utils.SftpNode(utils.NewSSHNode(user.Host, user.Port)), user.Username, ssh.Password(user.Password)) 38 | if err != nil { 39 | http.Error(w, "Error while establishing sftp connection", 400) 40 | log.Println("Error: while establishing sftp connection", err) 41 | return 42 | } 43 | // generate unique id. 44 | t := time.Now() 45 | entropy := rand.New(rand.NewSource(t.UnixNano())) 46 | id := ulid.MustNew(ulid.Timestamp(t), entropy) 47 | // add sftpEntity to list. 48 | utils.Join(id.String(), sftpEntity) // note:key is not for user auth, but for identify different connections. 49 | defer utils.Leave(id.String()) // close sftp connection anf remove sftpEntity from list. 50 | 51 | wsjson.Write(r.Context(), ws, models.SftpWebSocketMessage{Type: models.SftpWebSocketMessageTypeID, Data: id.String()}) 52 | 53 | // dispatch webSocket Messages. 54 | // process webSocket message one by one at present. todo improvement. 55 | for { 56 | _, _, err := ws.Read(r.Context()) 57 | if err != nil { 58 | log.Println("Error: error reading webSocket message:", err) 59 | break 60 | } 61 | //if err = DispatchSftpMessage(msgType, p, sftpEntity.sftpClient); err != nil { // todo handle heartbeat message and so on. 62 | // log.Println("Error: error write data to ssh server:", err) 63 | // break 64 | //} 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/sftp_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/sftp" 6 | "golang.org/x/crypto/ssh" 7 | "sync" 8 | ) 9 | 10 | type SftpNode Node // struct alias. 11 | 12 | type SftpEntity struct { 13 | sshEntity *SSHShellSession // from utils/ssh_utils 14 | sftpClient *sftp.Client // sftp session created by sshEntity.client.. 15 | } 16 | 17 | // close sftp session and ssh client 18 | func (con *SftpEntity) Close() error { 19 | var e error = nil 20 | // close sftp client 21 | if err := con.sftpClient.Close(); err != nil { // todo for debug. 22 | e = err 23 | } 24 | 25 | // close ssh 26 | if err := con.sshEntity.Close(); err != nil && e != nil { 27 | return fmt.Errorf("error closing sftp: %w: %s", err, e) 28 | } else if err != nil { // e is nil 29 | return fmt.Errorf("error closing sftp: %w", err) 30 | } 31 | return e 32 | } 33 | 34 | var ( 35 | sftpMutex = new(sync.RWMutex) 36 | subscribers = make(map[string]SftpEntity) 37 | ) 38 | 39 | func NewSftpEntity(user SftpNode, username string, auth ssh.AuthMethod) (SftpEntity, error) { 40 | sshEntity := SSHShellSession{ 41 | Node: NewSSHNode(user.Host, user.Port), 42 | } 43 | // init ssh connection. 44 | err := sshEntity.Connect(username, auth) 45 | if err != nil { 46 | return SftpEntity{}, err 47 | } 48 | 49 | // make a new sftp client 50 | if sshClient, err := sshEntity.GetClient(); err != nil { 51 | return SftpEntity{}, err 52 | } else { 53 | client, err := sftp.NewClient(sshClient) 54 | if err != nil { 55 | return SftpEntity{}, err 56 | } 57 | return SftpEntity{sshEntity: &sshEntity, sftpClient: client}, nil 58 | } 59 | } 60 | 61 | // add a sftp client to subscribers list. 62 | func Join(key string, sftpEntity SftpEntity) { 63 | sftpMutex.Lock() 64 | //subscribers.PushBack(client) 65 | if c, ok := subscribers[key]; ok { 66 | c.Close() // if client have exists, close the client. 67 | } 68 | subscribers[key] = sftpEntity // store sftpEntity. 69 | sftpMutex.Unlock() 70 | } 71 | 72 | // make a copy of SftpEntity matched with given key. 73 | // return sftpEntity and exist flag (bool). 74 | func Fork(key string) (SftpEntity, bool) { 75 | sftpMutex.Lock() 76 | defer sftpMutex.Unlock() 77 | //subscribers.PushBack(client) 78 | if c, ok := subscribers[key]; ok { 79 | return c, true 80 | } else { 81 | return SftpEntity{}, false 82 | } 83 | } 84 | 85 | // make a copy of SftpEntity matched with given key. 86 | // return sftp.client pointer or nil pointer. 87 | func ForkSftpClient(key string) *sftp.Client { 88 | sftpMutex.Lock() 89 | defer sftpMutex.Unlock() 90 | //subscribers.PushBack(client) 91 | if c, ok := subscribers[key]; ok { 92 | return c.sftpClient 93 | } else { 94 | return nil 95 | } 96 | } 97 | 98 | // remove a sftp client by key. 99 | func Leave(key string) { 100 | sftpMutex.Lock() 101 | //subscribers.PushBack(client) 102 | if c, ok := subscribers[key]; ok { 103 | c.Close() // close the client. 104 | delete(subscribers, key) // remove from map. 105 | } 106 | sftpMutex.Unlock() 107 | } 108 | -------------------------------------------------------------------------------- /src/routers/router.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | "github.com/genshen/ssh-web-console/src/controllers" 5 | "github.com/genshen/ssh-web-console/src/controllers/files" 6 | "github.com/genshen/ssh-web-console/src/utils" 7 | _ "github.com/genshen/ssh-web-console/statik" 8 | "github.com/rakyll/statik/fs" 9 | "log" 10 | "net/http" 11 | "os" 12 | ) 13 | 14 | const ( 15 | RunModeDev = "dev" 16 | RunModeProd = "prod" 17 | ) 18 | 19 | func Register() { 20 | // serve static files 21 | // In dev mode, resource files (for example /static/*) and views(fro example /index.html) are served separately. 22 | // In production mode, resource files and views are served by statikFS (for example /*). 23 | if utils.Config.Site.RunMode == RunModeDev { 24 | if utils.Config.Dev.StaticPrefix == utils.Config.Dev.ViewsPrefix { 25 | log.Fatal(`static prefix and views prefix can not be the same, check your config.`) 26 | return 27 | } 28 | // server resource files 29 | if utils.Config.Dev.StaticRedirect == "" { 30 | // serve locally 31 | localFile := justFilesFilesystem{http.Dir(utils.Config.Dev.StaticDir)} 32 | http.Handle(utils.Config.Dev.StaticPrefix, http.StripPrefix(utils.Config.Dev.StaticPrefix, http.FileServer(localFile))) 33 | } else { 34 | // serve by redirection 35 | http.HandleFunc(utils.Config.Dev.StaticPrefix, func(writer http.ResponseWriter, req *http.Request) { 36 | http.Redirect(writer, req, utils.Config.Dev.StaticRedirect+req.URL.Path, http.StatusMovedPermanently) 37 | }) 38 | } 39 | // serve views files. 40 | utils.MemStatic(utils.Config.Dev.ViewsDir) 41 | http.HandleFunc(utils.Config.Dev.ViewsPrefix, func(w http.ResponseWriter, r *http.Request) { 42 | utils.ServeHTTP(w, r) // server soft static files. 43 | }) 44 | } else { 45 | statikFS, err := fs.New() 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | http.Handle(utils.Config.Prod.StaticPrefix, http.StripPrefix(utils.Config.Prod.StaticPrefix, http.FileServer(statikFS))) 50 | } 51 | 52 | // set api prefix 53 | apiPrefix := "" 54 | if utils.Config.Site.RunMode == RunModeDev && utils.Config.Dev.ApiPrefix != "" { 55 | apiPrefix = utils.Config.Dev.ApiPrefix 56 | } 57 | if utils.Config.Site.RunMode == RunModeProd && utils.Config.Prod.ApiPrefix != "" { 58 | apiPrefix = utils.Config.Prod.ApiPrefix 59 | } 60 | if apiPrefix == "" { 61 | log.Println("api serving at endpoint `/`") 62 | } else { 63 | log.Printf("api serving at endpoint `%s`", apiPrefix) 64 | } 65 | 66 | bct := utils.Config.SSH.BufferCheckerCycleTime 67 | // api 68 | http.HandleFunc(apiPrefix+"/api/signin", controllers.SignIn) 69 | http.HandleFunc(apiPrefix+"/api/sftp/upload", controllers.AuthPreChecker(files.FileUpload{})) 70 | http.HandleFunc(apiPrefix+"/api/sftp/ls", controllers.AuthPreChecker(files.List{})) 71 | http.HandleFunc(apiPrefix+"/api/sftp/dl", controllers.AuthPreChecker(files.Download{})) 72 | http.HandleFunc(apiPrefix+"/ws/ssh", controllers.AuthPreChecker(controllers.NewSSHWSHandle(bct))) 73 | http.HandleFunc(apiPrefix+"/ws/sftp", controllers.AuthPreChecker(files.SftpEstablish{})) 74 | } 75 | 76 | /* 77 | * disable directory index, code from https://groups.google.com/forum/#!topic/golang-nuts/bStLPdIVM6w 78 | */ 79 | type justFilesFilesystem struct { 80 | fs http.FileSystem 81 | } 82 | 83 | func (fs justFilesFilesystem) Open(name string) (http.File, error) { 84 | f, err := fs.fs.Open(name) 85 | if err != nil { 86 | return nil, err 87 | } 88 | return neuteredReaddirFile{f}, nil 89 | } 90 | 91 | type neuteredReaddirFile struct { 92 | http.File 93 | } 94 | 95 | func (f neuteredReaddirFile) Readdir(count int) ([]os.FileInfo, error) { 96 | return nil, nil 97 | } 98 | -------------------------------------------------------------------------------- /src/controllers/websocket.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/genshen/ssh-web-console/src/models" 7 | "github.com/genshen/ssh-web-console/src/utils" 8 | "golang.org/x/crypto/ssh" 9 | "io" 10 | "log" 11 | "net/http" 12 | "nhooyr.io/websocket" 13 | "time" 14 | ) 15 | 16 | //const SSH_EGG = `genshen https://github.com/genshen/sshWebConsole"` 17 | 18 | type SSHWebSocketHandle struct { 19 | bufferFlushCycle int 20 | } 21 | 22 | func NewSSHWSHandle(bfc int) *SSHWebSocketHandle { 23 | var handle SSHWebSocketHandle 24 | handle.bufferFlushCycle = bfc 25 | return &handle 26 | } 27 | 28 | // clear session after ssh closed. 29 | func (c *SSHWebSocketHandle) ShouldClearSessionAfterExec() bool { 30 | return true 31 | } 32 | 33 | // handle webSocket connection. 34 | func (c *SSHWebSocketHandle) ServeAfterAuthenticated(w http.ResponseWriter, r *http.Request, claims *utils.Claims, session utils.Session) { 35 | // init webSocket connection 36 | conn, err := websocket.Accept(w, r, nil) 37 | if err != nil { 38 | http.Error(w, "Cannot setup WebSocket connection:", 400) 39 | log.Println("Error: Cannot setup WebSocket connection:", err) 40 | return 41 | } 42 | defer conn.Close(websocket.StatusNormalClosure, "closed") 43 | 44 | userInfo := session.Value.(models.UserInfo) 45 | cols := utils.GetQueryInt32(r, "cols", 120) 46 | rows := utils.GetQueryInt32(r, "rows", 32) 47 | sshAuth := ssh.Password(userInfo.Password) 48 | if err := c.SSHShellOverWS(r.Context(), conn, claims.Host, claims.Port, userInfo.Username, sshAuth, cols, rows); err != nil { 49 | log.Println("Error,", err) 50 | utils.Abort(w, err.Error(), 500) 51 | } 52 | } 53 | 54 | // ssh shell over websocket 55 | // first,we establish a ssh connection to ssh server when a webSocket comes; 56 | // then we deliver ssh data via ssh connection between browser and ssh server. 57 | // That is, read webSocket data from browser (e.g. 'ls' command) and send data to ssh server via ssh connection; 58 | // the other hand, read returned ssh data from ssh server and write back to browser via webSocket API. 59 | func (c *SSHWebSocketHandle) SSHShellOverWS(ctx context.Context, ws *websocket.Conn, host string, port int, username string, auth ssh.AuthMethod, cols, rows uint32) error { 60 | //setup ssh connection 61 | sshEntity := utils.SSHShellSession{ 62 | Node: utils.NewSSHNode(host, port), 63 | } 64 | // set io for ssh session 65 | var wsBuff WebSocketBufferWriter 66 | sshEntity.WriterPipe = &wsBuff 67 | 68 | var sshConn utils.SSHConnInterface = &sshEntity // set interface 69 | err := sshConn.Connect(username, auth) 70 | if err != nil { 71 | return fmt.Errorf("cannot setup ssh connection %w", err) 72 | } 73 | defer sshConn.Close() 74 | 75 | // config ssh 76 | sshSession, err := sshConn.Config(cols, rows) 77 | if err != nil { 78 | return fmt.Errorf("configure ssh error: %w", err) 79 | } 80 | 81 | // an egg: 82 | //if err := sshSession.Setenv("SSH_EGG", SSH_EGG); err != nil { 83 | // log.Println(err) 84 | //} 85 | // after configure, the WebSocket is ok. 86 | defer wsBuff.Flush(ctx, websocket.MessageBinary, ws) 87 | 88 | done := make(chan bool, 3) 89 | setDone := func() { done <- true } 90 | 91 | // most messages are ssh output, not webSocket input 92 | writeMessageToSSHServer := func(wc io.WriteCloser) { // read messages from webSocket 93 | defer setDone() 94 | for { 95 | msgType, p, err := ws.Read(ctx) 96 | // if WebSocket is closed by some reason, then this func will return, 97 | // and 'done' channel will be set, the outer func will reach to the end. 98 | // then ssh session will be closed in defer. 99 | if err != nil { 100 | log.Println("Error: error reading webSocket message:", err) 101 | return 102 | } 103 | if err = DispatchMessage(sshSession, msgType, p, wc); err != nil { 104 | log.Println("Error: error write data to ssh server:", err) 105 | return 106 | } 107 | } 108 | } 109 | 110 | stopper := make(chan bool) // timer stopper 111 | // check webSocketWriterBuffer(if not empty,then write back to webSocket) every 120 ms. 112 | writeBufferToWebSocket := func() { 113 | defer setDone() 114 | tick := time.NewTicker(time.Millisecond * time.Duration(c.bufferFlushCycle)) 115 | //for range time.Tick(120 * time.Millisecond){} 116 | defer tick.Stop() 117 | for { 118 | select { 119 | case <-tick.C: 120 | if err := wsBuff.Flush(ctx, websocket.MessageBinary, ws); err != nil { 121 | log.Println("Error: error sending data via webSocket:", err) 122 | return 123 | } 124 | case <-stopper: 125 | return 126 | } 127 | } 128 | } 129 | 130 | go writeMessageToSSHServer(sshEntity.StdinPipe) 131 | go writeBufferToWebSocket() 132 | go func() { 133 | defer setDone() 134 | if err := sshSession.Wait(); err != nil { 135 | log.Println("ssh exist from server", err) 136 | } 137 | // if ssh is closed (wait returns), then 'done', web socket will be closed. 138 | // by the way, buffered data will be flushed before closing WebSocket. 139 | }() 140 | 141 | <-done 142 | 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). ) 143 | log.Println("Info: websocket finished!") 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 4 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 5 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 6 | github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= 7 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 8 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 9 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 10 | github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= 11 | github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= 12 | github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 13 | github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= 14 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 15 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 16 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 17 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 18 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 19 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 20 | github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= 21 | github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= 22 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 23 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 24 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 25 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 26 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 27 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 28 | github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= 29 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 30 | github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc= 31 | github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= 32 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 33 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 34 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 35 | github.com/pkg/sftp v1.12.0 h1:/f3b24xrDhkhddlaobPe2JgBqfdt+gC/NYl0QY9IOuI= 36 | github.com/pkg/sftp v1.12.0/go.mod h1:fUqqXB5vEgVCZ131L+9say31RAri6aF6KDViawhxKK8= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ= 39 | github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc= 40 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 41 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 42 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 43 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 44 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 45 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 46 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 47 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= 48 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 49 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 50 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 51 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 54 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 55 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 56 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 57 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 59 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 60 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 61 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 62 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= 63 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | nhooyr.io/websocket v1.8.6 h1:s+C3xAMLwGmlI31Nyn/eAehUlZPwfYZu2JXM621Q5/k= 65 | nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= 66 | -------------------------------------------------------------------------------- /src/utils/soft_static.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "mime" 13 | "net/http" 14 | "os" 15 | "path/filepath" 16 | "strconv" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | // serve all views files from memory storage. 22 | // basic idea: https://github.com/bouk/staticfiles 23 | type staticFilesFile struct { 24 | data []byte 25 | mime string 26 | mtime time.Time 27 | // size is the size before compression. If 0, it means the data is uncompressed 28 | size int64 29 | // hash is a sha256 hash of the file contents. Used for the Etag, and useful for caching 30 | hash string 31 | } 32 | 33 | var staticFiles = make(map[string]*staticFilesFile) 34 | 35 | // NotFound is called when no asset is found. 36 | // It defaults to http.NotFound but can be overwritten 37 | var NotFound = http.NotFound 38 | 39 | // read all files in views directory and map to "staticFiles" 40 | func MemStatic(staticDir string) { 41 | files := processDir(staticDir, "") 42 | for _, file := range files { 43 | var b bytes.Buffer 44 | var b2 bytes.Buffer 45 | hash := sha256.New() 46 | 47 | f, err := os.Open(filepath.Join(staticDir, file)) 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | stat, err := f.Stat() 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | if _, err := b.ReadFrom(f); err != nil { 56 | log.Fatal(err) 57 | } 58 | f.Close() 59 | 60 | compressedWriter, _ := gzip.NewWriterLevel(&b2, gzip.BestCompression) 61 | writer := io.MultiWriter(compressedWriter, hash) 62 | if _, err := writer.Write(b.Bytes()); err != nil { 63 | log.Fatal(err) 64 | } 65 | compressedWriter.Close() 66 | file = strings.Replace(file, "\\", "/", -1) 67 | if b2.Len() < b.Len() { 68 | staticFiles[file] = &staticFilesFile{ 69 | data: b2.Bytes(), 70 | mime: mime.TypeByExtension(filepath.Ext(file)), 71 | mtime: time.Unix(stat.ModTime().Unix(), 0), 72 | size: stat.Size(), 73 | hash: hex.EncodeToString(hash.Sum(nil)), 74 | } 75 | } else { 76 | staticFiles[file] = &staticFilesFile{ 77 | data: b.Bytes(), 78 | mime: mime.TypeByExtension(filepath.Ext(file)), 79 | mtime: time.Unix(stat.ModTime().Unix(), 0), 80 | hash: hex.EncodeToString(hash.Sum(nil)), 81 | } 82 | } 83 | b.Reset() 84 | b2.Reset() 85 | hash.Reset() 86 | } 87 | } 88 | 89 | // todo large memory!! 90 | func processDir(prefix, dir string) (fileSlice []string) { 91 | files, err := ioutil.ReadDir(filepath.Join(prefix, dir)) 92 | var allFiles []string 93 | if err != nil { 94 | log.Fatal(err) 95 | } 96 | for _, file := range files { 97 | if strings.HasPrefix(file.Name(), ".") { 98 | continue 99 | } 100 | 101 | dir := filepath.Join(dir, file.Name()) 102 | //if skipFile(path.Join(id...), excludeSlice) { 103 | // continue 104 | //} 105 | 106 | if file.IsDir() { 107 | for _, v := range processDir(prefix, dir) { 108 | allFiles = append(allFiles, v) 109 | } 110 | } else { 111 | allFiles = append(allFiles, dir) 112 | } 113 | } 114 | return allFiles 115 | } 116 | 117 | // ServeHTTP serves a request, attempting to reply with an embedded file. 118 | func ServeHTTP(rw http.ResponseWriter, req *http.Request) { 119 | filename := strings.TrimPrefix(req.URL.Path, "/") 120 | if f, ok := staticFiles[filename]; ok { 121 | serveHTTPByName(rw, req, f) 122 | return 123 | } 124 | // try index.html 125 | if strings.HasSuffix(req.URL.Path, "/") { 126 | filename += "index.html" 127 | if f, ok := staticFiles[filename]; ok { 128 | serveHTTPByName(rw, req, f) 129 | return 130 | } 131 | } 132 | // return 404 if both of them not exists 133 | NotFound(rw, req) 134 | } 135 | 136 | // ServeHTTPByName serves a request by the key(param filename) in map. 137 | func serveHTTPByName(rw http.ResponseWriter, req *http.Request, f *staticFilesFile) { 138 | header := rw.Header() 139 | if f.hash != "" { 140 | if hash := req.Header.Get("If-None-Match"); hash == f.hash { 141 | rw.WriteHeader(http.StatusNotModified) 142 | return 143 | } 144 | header.Set("ETag", f.hash) 145 | } 146 | if !f.mtime.IsZero() { 147 | if t, err := time.Parse(http.TimeFormat, req.Header.Get("If-Modified-Since")); err == nil && f.mtime.Before(t.Add(1*time.Second)) { 148 | rw.WriteHeader(http.StatusNotModified) 149 | return 150 | } 151 | header.Set("Last-Modified", f.mtime.UTC().Format(http.TimeFormat)) 152 | } 153 | header.Set("Content-Type", f.mime) 154 | 155 | // Check if the asset is compressed in the binary 156 | if f.size == 0 { // not compressed 157 | header.Set("Content-Length", strconv.Itoa(len(f.data))) 158 | rw.Write(f.data) 159 | } else { 160 | if header.Get("Content-Encoding") == "" && strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") { 161 | header.Set("Content-Encoding", "gzip") 162 | header.Set("Content-Length", strconv.Itoa(len(f.data))) 163 | rw.Write(f.data) 164 | } else { 165 | header.Set("Content-Length", strconv.Itoa(int(f.size))) 166 | reader, _ := gzip.NewReader(bytes.NewReader(f.data)) 167 | io.Copy(rw, reader) 168 | reader.Close() 169 | } 170 | } 171 | } 172 | 173 | // Server is simply ServeHTTP but wrapped in http.HandlerFunc so it can be passed into net/http functions directly. 174 | var Server http.Handler = http.HandlerFunc(ServeHTTP) 175 | 176 | // Open allows you to read an embedded file directly. It will return a decompressing Reader if the file is embedded in compressed format. 177 | // You should close the Reader after you're done with it. 178 | func Open(name string) (io.ReadCloser, error) { 179 | f, ok := staticFiles[name] 180 | if !ok { 181 | return nil, fmt.Errorf("Asset %s not found", name) 182 | } 183 | 184 | if f.size == 0 { 185 | return ioutil.NopCloser(bytes.NewReader(f.data)), nil 186 | } 187 | return gzip.NewReader(bytes.NewReader(f.data)) 188 | } 189 | 190 | // ModTime returns the modification time of the original file. 191 | // Useful for caching purposes 192 | // Returns zero time if the file is not in the bundle 193 | func ModTime(file string) (t time.Time) { 194 | if f, ok := staticFiles[file]; ok { 195 | t = f.mtime 196 | } 197 | return 198 | } 199 | 200 | // Hash returns the hex-encoded SHA256 hash of the original file 201 | // Used for the Etag, and useful for caching 202 | // Returns an empty string if the file is not in the bundle 203 | func Hash(file string) (s string) { 204 | if f, ok := staticFiles[file]; ok { 205 | s = f.hash 206 | } 207 | return 208 | } 209 | -------------------------------------------------------------------------------- /src/utils/ssh_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "golang.org/x/crypto/ssh" 7 | "io" 8 | "log" 9 | "net" 10 | "strconv" 11 | ) 12 | 13 | const ( 14 | SSH_IO_MODE_CHANNEL = 0 15 | SSH_IO_MODE_SESSION = 1 16 | ) 17 | 18 | type SSHConnInterface interface { 19 | // close ssh connection 20 | Close() error 21 | // connect using username and password 22 | Connect(username string, auth ssh.AuthMethod) error 23 | // config connection after connected and may also create a ssh session. 24 | Config(cols, rows uint32) (*ssh.Session, error) 25 | } 26 | 27 | type Node struct { 28 | Host string // host, e.g: ssh.example.com 29 | Port int //port,default value is 22 30 | client *ssh.Client 31 | } 32 | 33 | func NewSSHNode(host string, port int) Node { 34 | return Node{Host: host, Port: port, client: nil} 35 | } 36 | 37 | func (node *Node) GetClient() (*ssh.Client, error) { 38 | if node.client == nil { 39 | return nil, errors.New("client is not set") 40 | } 41 | return node.client, nil 42 | } 43 | 44 | //see: http://www.nljb.net/default/Go-SSH-%E4%BD%BF%E7%94%A8/ 45 | // establish a ssh connection. if success return nil, than can operate ssh connection via pointer Node.client in struct Node. 46 | func (node *Node) Connect(username string, auth ssh.AuthMethod) error { 47 | //var hostKey ssh.PublicKey 48 | 49 | // An SSH client is represented with a ClientConn. 50 | // 51 | // To authenticate with the remote server you must pass at least one 52 | // implementation of AuthMethod via the Auth field in ClientConfig, 53 | // and provide a HostKeyCallback. 54 | config := &ssh.ClientConfig{ 55 | User: username, 56 | Auth: []ssh.AuthMethod{ 57 | auth, 58 | }, 59 | //HostKeyCallback: ssh.FixedHostKey(hostKey), 60 | HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { 61 | return nil 62 | }, 63 | } 64 | 65 | client, err := ssh.Dial("tcp", net.JoinHostPort(node.Host, strconv.Itoa(node.Port)), config) 66 | if err != nil { 67 | return err 68 | } 69 | node.client = client 70 | return nil 71 | } 72 | 73 | // connect to ssh server using ssh session. 74 | type SSHShellSession struct { 75 | Node 76 | // calling Write() to write data to ssh server 77 | StdinPipe io.WriteCloser 78 | // Write() be called to receive data from ssh server 79 | WriterPipe io.Writer 80 | session *ssh.Session 81 | } 82 | 83 | // setup ssh shell session 84 | // set SSHShellSession.session and StdinPipe from created session here. 85 | // and Session.Stdout and Session.Stderr are also set for outputting. 86 | // Return value is a pointer of ssh session which is created by ssh client for shell interaction. 87 | // If it has error in this func, ssh session will be nil. 88 | func (s *SSHShellSession) Config(cols, rows uint32) (*ssh.Session, error) { 89 | session, err := s.client.NewSession() 90 | if err != nil { 91 | return nil, err 92 | } 93 | s.session = session 94 | 95 | // we set stdin, then we can write data to ssh server via this stdin. 96 | // but, as for reading data from ssh server, we can set Session.Stdout and Session.Stderr 97 | // to receive data from ssh server, and write back to somewhere. 98 | if stdin, err := session.StdinPipe(); err != nil { 99 | log.Fatal("failed to set IO stdin: ", err) 100 | return nil, err 101 | } else { 102 | // in fact, stdin it is channel. 103 | s.StdinPipe = stdin 104 | } 105 | 106 | // set writer, such the we can receive ssh server's data and write the data to somewhere specified by WriterPipe. 107 | if s.WriterPipe == nil { 108 | return nil, errors.New("WriterPipe is nil") 109 | } 110 | session.Stdout = s.WriterPipe 111 | session.Stderr = s.WriterPipe 112 | 113 | modes := ssh.TerminalModes{ 114 | ssh.ECHO: 1, // disable echo 115 | ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud 116 | ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud 117 | } 118 | // Request pseudo terminal 119 | if err := session.RequestPty("xterm", int(rows), int(cols), modes); err != nil { 120 | log.Fatal("request for pseudo terminal failed: ", err) 121 | return nil, err 122 | } 123 | // Start remote shell 124 | if err := session.Shell(); err != nil { 125 | log.Fatal("failed to start shell: ", err) 126 | return nil, err 127 | } 128 | return session, nil 129 | } 130 | 131 | func (s *SSHShellSession) Close() error { 132 | var e error = nil 133 | // close session first 134 | if s.session != nil { 135 | if err := s.session.Close(); err != nil { 136 | e = err 137 | } 138 | } 139 | 140 | // try to close client 141 | if s.client != nil { 142 | if err := s.client.Close(); err != nil && e != nil { 143 | return fmt.Errorf("error closing ssh client: %w: %s", err, e.Error()) 144 | } else if err != nil { // e is nil 145 | return fmt.Errorf("error closing ssh client: %w", err) 146 | } 147 | } 148 | return e 149 | } 150 | 151 | // deprecated, use session SSHShellSession instead 152 | // connect to ssh server using channel. 153 | type SSHShellChannel struct { 154 | Node 155 | Channel ssh.Channel 156 | } 157 | 158 | type ptyRequestMsg struct { 159 | Term string 160 | Columns uint32 161 | Rows uint32 162 | Width uint32 163 | Height uint32 164 | Modelist string 165 | } 166 | 167 | func (ch *SSHShellChannel) Config(cols, rows uint32) error { 168 | channel, requests, err := ch.client.Conn.OpenChannel("session", nil) 169 | if err != nil { 170 | return err 171 | } 172 | ch.Channel = channel 173 | 174 | go func() { 175 | for req := range requests { 176 | if req.WantReply { 177 | req.Reply(false, nil) 178 | } 179 | } 180 | }() 181 | 182 | //see https://github.com/golang/crypto/blob/master/ssh/example_test.go 183 | modes := ssh.TerminalModes{ //todo configure 184 | ssh.ECHO: 1, 185 | ssh.TTY_OP_ISPEED: 14400, 186 | ssh.TTY_OP_OSPEED: 14400, 187 | } 188 | var modeList []byte 189 | for k, v := range modes { 190 | kv := struct { 191 | Key byte 192 | Val uint32 193 | }{k, v} 194 | modeList = append(modeList, ssh.Marshal(&kv)...) 195 | } 196 | modeList = append(modeList, 0) 197 | req := ptyRequestMsg{ //todo configure 198 | Term: "xterm", 199 | Columns: cols, 200 | Rows: rows, 201 | Width: cols * 8, 202 | Height: rows * 8, 203 | Modelist: string(modeList), 204 | } 205 | 206 | ok, err := channel.SendRequest("pty-req", true, ssh.Marshal(&req)) 207 | if !ok || err != nil { 208 | return errors.New("error sending pty-request" + 209 | func() string { 210 | if err == nil { 211 | return "" 212 | } 213 | return err.Error() 214 | }()) 215 | } 216 | 217 | ok, err = channel.SendRequest("shell", true, nil) 218 | if !ok || err != nil { 219 | return errors.New("error sending shell-request" + 220 | func() string { 221 | if err == nil { 222 | return "" 223 | } 224 | return err.Error() 225 | }()) 226 | } 227 | return nil 228 | } 229 | --------------------------------------------------------------------------------