Compare commits

..

22 Commits

Author SHA1 Message Date
Evan Chen ddf5e86cbc build: add s390x arch 2021-12-29 14:15:27 +08:00
Evan Chen faf8300d8f feat: #3 2021-11-24 23:02:50 +08:00
Evan Chen f309009a76 fix: #4 2021-11-19 17:19:50 +08:00
Evan Chen 7aec30d473 fix: #2 2021-11-19 15:47:47 +08:00
Evan Chen 9728bfab58 fix: TERM env not set 2021-11-18 15:32:50 +08:00
Evan Chen 6828e6397a docs: add log
continuous-integration/drone/tag Build is passing Details
2021-11-18 14:50:57 +08:00
Evan Chen b6b0774531 feat: start new cmd with custom args in ?cmd=
continuous-integration/drone/tag Build is passing Details
2021-11-18 13:54:29 +08:00
Evan Chen 6a75a1e27f chore: update profile
continuous-integration/drone/tag Build is passing Details
2021-11-18 11:24:49 +08:00
Evan Chen af0fc1b352 docs: update usage prompt 2021-11-18 11:10:35 +08:00
Evan Chen 2fecf640de build: make nocgo default 2021-11-18 10:51:41 +08:00
Evan Chen edff8721cf feat: add dir option
continuous-integration/drone/tag Build is passing Details
2021-11-18 10:49:15 +08:00
Evan Chen 65f9eac352 fix: default key event not prevented 2021-11-18 10:48:48 +08:00
Evan 4c5fe03d2d remove debug print 2021-11-18 00:28:34 +08:00
Evan 1da1d4c1f7 update 2021-11-18 00:23:13 +08:00
Evan deb22f8df1 feat: add default profile 2021-11-18 00:10:35 +08:00
Evan Chen 95bca77f37 fix: query param cause 404 notfound 2021-11-17 16:05:20 +08:00
Evan Chen 96209b847a fix: rotate back to 1 shell count 2021-11-17 12:48:10 +08:00
Evan Chen a5e826e284 fix: vim full screen
continuous-integration/drone/tag Build is passing Details
2021-11-17 12:28:47 +08:00
Evan Chen a3b2e0026d fix: relative path
continuous-integration/drone/tag Build is passing Details
2021-11-17 02:16:35 +08:00
Evan Chen 9bb3c18df9 update
continuous-integration/drone/tag Build is passing Details
2021-11-17 02:04:21 +08:00
Evan Chen d379ffff9b update 2021-11-16 23:57:32 +08:00
Evan Chen 12084a659c update 2021-11-16 18:50:52 +08:00
42 changed files with 5376 additions and 9258 deletions

20
.drone.yml Normal file
View File

@ -0,0 +1,20 @@
kind: pipeline
name: default
steps:
- name: build
image: golang:1.17.2
commands:
- git tag $DRONE_TAG
- bash make.sh
- echo -n "latest,${DRONE_TAG#v}" > .tags
- name: gitea_release
image: plugins/gitea-release
settings:
api_key:
from_secret: gitea_api_key
base_url: https://kumoly.io
files: dist/*
checksum:
- sha256
trigger:
event: tag

2
.gitignore vendored
View File

@ -1 +1,3 @@
node_modules
.parcel-cache
dist

View File

@ -1 +1,40 @@
# goshell
# gterm
## Usage
```shell
Usage: gterm [options]
-addr string
address to bind (default ":8000")
-allow string
restrict ip
-arg value
additional args to pass to cmd, multiple args can be passed, ex. -arg a -arg b
-dev
set the system to development mode
-dir string
the working dir that the shell will start from
-log-level int
log level, error:1 debug:2 warn:4 info:8 (default 9)
-name string
the application name (default "gterm")
-profile
print default profile, could be invoked with <(..)
-shell string
the shell to use (default "bash")
-v show version
```
### run bash with default profile
```shell
gterm -arg "--rcfile" -arg <(gterm -profile)
```
## Install
```shell
sudo rm -f /usr/local/bin/gterm
sudo sh -c "curl -fsSL RELEASE_URL | tar -C /usr/local/bin/ -xz"
```

13
build.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
VERSION=$(git describe --tags --abbrev=0)
BUILD=$(git rev-parse --short HEAD)
PROJ=gterm
DIST=dist
LDFLAGS="-ldflags \"-X main.Version=${VERSION} -X main.Build=${BUILD} -w -s\""
BIN_FILENAME="${PROJ}"
CMD="CGO_ENABLED=0 go build ${LDFLAGS} -o ${DIST}/${BIN_FILENAME} cmd/gterm/main.go"
echo "${CMD}"
eval $CMD

122
cmd/gterm/main.go Normal file
View File

@ -0,0 +1,122 @@
package main
import (
"flag"
"fmt"
"net"
"net/http"
"os"
"strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"kumoly.io/lib/guard"
"kumoly.io/tools/gterm"
)
type arrayFlags []string
func (i *arrayFlags) String() string {
return strings.Join(*i, ", ")
}
func (i *arrayFlags) Set(value string) error {
*i = append(*i, strings.TrimSpace(value))
return nil
}
var Version = "0.0.0"
var Build = "alpha"
var (
flagAllowIPNet string
flagAppName string
flagAddr string
flagShell string
flagDir string
flagLogLevel int
flagLogPretty bool
flagDev bool
flagVer bool
flagArgs arrayFlags
flagProfile bool
flagSalt string
flagUsr string
flagPasswd string
)
func init() {
flag.StringVar(&flagAppName, "name", "gterm", "the application name")
flag.StringVar(&flagAddr, "addr", ":8000", "address to bind")
flag.StringVar(&flagShell, "shell", "bash", "the shell to use")
flag.StringVar(&flagDir, "dir", "", "the working dir that the shell will start from")
flag.Var(&flagArgs, "arg", "additional args to pass to cmd, multiple args can be passed, ex. -arg a -arg b")
flag.BoolVar(&flagDev, "dev", false, "set the system to development mode")
flag.IntVar(&flagLogLevel, "log-level", 1, "log level [-1(trace):5(panic)] 7 to disable")
flag.StringVar(&flagAllowIPNet, "allow", "", "restrict ip in a specific ip net")
flag.BoolVar(&flagProfile, "profile", false, "print default profile, could be invoked with <(..)")
flag.StringVar(&flagSalt, "salt", "", "add salt to encoded keyparam")
flag.BoolVar(&flagVer, "v", false, "show version")
flag.BoolVar(&flagLogPretty, "pretty", false, "log message in human readable format (the original log is json)")
flag.StringVar(&flagUsr, "usr", "", "username, use basic auth for authentication if user and password are set")
flag.StringVar(&flagPasswd, "passwd", "", "password, use basic auth for authentication if user and password are set")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: gterm [options]\n")
flag.PrintDefaults()
}
}
func main() {
flag.Parse()
if flagVer {
fmt.Printf("%v - %v\n", Version, Build)
return
}
if flagProfile {
fmt.Println(gterm.BashProfile)
return
}
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
zerolog.SetGlobalLevel(zerolog.Level(flagLogLevel))
if flagLogPretty {
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "2006/01/02 15:04:05",
})
}
if flagDev {
log.Logger = log.With().Caller().Logger()
}
log.Logger = log.With().Str("mod", "gtrem").Logger()
g := gterm.New()
g.AppName = flagAppName
g.Cmd = flagShell
g.Args = flagArgs
g.Dir = flagDir
g.Salt = flagSalt
gd := guard.New()
if flagAllowIPNet != "" {
_, ipnet, err := net.ParseCIDR(flagAllowIPNet)
if err != nil {
log.Panic().Err(err).Msg("")
}
gd.AllowIPNet = ipnet
}
if flagUsr != "" && flagPasswd != "" {
gd.SetBasicAuth(flagUsr, flagPasswd)
}
server := &http.Server{
Addr: flagAddr,
Handler: gd.Guard(g),
}
log.Info().Msgf("gterm starting at %s", flagAddr)
err := server.ListenAndServe()
if err != nil {
panic(err)
}
}

7
go.mod
View File

@ -1,15 +1,18 @@
module kumoly.io/tools/goshell
module kumoly.io/tools/gterm
go 1.17
require (
github.com/creack/pty v1.1.17
github.com/gorilla/websocket v1.4.2
kumoly.io/lib/klog v0.0.8
github.com/rs/zerolog v1.26.0
kumoly.io/lib/guard v0.1.1
kumoly.io/lib/ksrv v0.0.2-0.20211112060911-0d61b343a298
kumoly.io/lib/xorencrypt v0.1.0
)
require (
github.com/mattn/go-isatty v0.0.14 // indirect
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
kumoly.io/lib/klog v0.0.8 // indirect
)

35
go.sum
View File

@ -1,13 +1,48 @@
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.26.0 h1:ORM4ibhEZeTeQlCojCK2kPz1ogAY4bGs4tD+SaAdGaE=
github.com/rs/zerolog v1.26.0/go.mod h1:yBiM87lvSqX8h0Ww4sdzNSkVYZ8dL2xjZJG1lAuGZEo=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
kumoly.io/lib/guard v0.1.0 h1:PvFM0bcWbgfZQv4QfRwDmrK9FAjv9QYwfJY3Ffg6JA0=
kumoly.io/lib/guard v0.1.0/go.mod h1:yWg9RDSI6YXkOPmP6Ad93aMqzlxhgW8LOe/ZRjjYX3U=
kumoly.io/lib/guard v0.1.1 h1:aUcn0qVtX6TqRhp7bWSjIRlbWRWtuK0cjCArILTbAcY=
kumoly.io/lib/guard v0.1.1/go.mod h1:yWg9RDSI6YXkOPmP6Ad93aMqzlxhgW8LOe/ZRjjYX3U=
kumoly.io/lib/klog v0.0.8 h1:6hTfDlZh7KGnPrd2tUrauCKRImSnyyN9DHXpey3Czn8=
kumoly.io/lib/klog v0.0.8/go.mod h1:Snm+c1xRrh/RbXsxQf7UGYbAJGPcIa6bEEN+CmzJh7M=
kumoly.io/lib/ksrv v0.0.2-0.20211112060911-0d61b343a298 h1:0raqoIXmNpD6s1SrJbieAyIIkDyhe+aqfaXvx8wenrI=
kumoly.io/lib/ksrv v0.0.2-0.20211112060911-0d61b343a298/go.mod h1:pwd+NspxnoxPJAETRY2V4i2qZc+orKLxvWzGUBiqBW8=
kumoly.io/lib/xorencrypt v0.1.0 h1:VssGocaBAPyLn+QURVY8FdFYRBmwFr26z7ame0zwV44=
kumoly.io/lib/xorencrypt v0.1.0/go.mod h1:+L3JtdD/CTlcXvE8X7AvOJBVbN16qvnxxv3zIWPZgQM=

362
gterm.go Normal file
View File

@ -0,0 +1,362 @@
package gterm
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/creack/pty"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
"kumoly.io/lib/ksrv"
"kumoly.io/lib/ksrv/engine"
"kumoly.io/lib/xorencrypt"
"kumoly.io/tools/gterm/public"
)
//go:embed public/index.html
var index string
//go:embed profile
var BashProfile string
var tmpl *engine.Engine
var servePublic = http.FileServer(http.FS(public.FS))
var pool map[string]chan struct{}
var locks map[string]chan struct{}
func init() {
tmpl = engine.Must(engine.New("").Parse(index))
pool = map[string]chan struct{}{}
locks = map[string]chan struct{}{}
}
type GTerm struct {
AppName string
// Arguments is a list of strings to pass as arguments to the specified Command
Args []string
// Command is the path to the binary we should create a TTY for
Cmd string
// Dir working dir
Dir string
// Envs env pairs to pass to the command
Envs []string
// ErrorLimit defines the number of consecutive errors that can happen
// before a connection is considered unusable
ErrorLimit int
// Timeout defines the maximum duration between which a ping and pong
// cycle should be tolerated, beyond this the connection should be deemed dead
Timeout time.Duration
BufferSize int
Salt string
}
func New() *GTerm {
// if g.Cmd == "" {
// g.Cmd = "bash"
// }
// if len(g.Envs) == 0 {
// g.Envs = os.Environ()
// }
// if g.Timeout < time.Second {
// g.Timeout = 20 * time.Second
// }
// if g.ErrorLimit == 0 {
// g.ErrorLimit = 10
// }
// if g.BufferSize == 0 {
// g.BufferSize = 512
// }
go func() {
//print pool
for {
cons := []string{}
for k := range pool {
cons = append(cons, k)
}
log.Info().Interface("connections", cons).Msg("")
time.Sleep(5 * time.Minute)
}
}()
return &GTerm{
AppName: "GTERM",
Cmd: "bash",
Envs: append(os.Environ(), "TERM=xterm-256color"),
Timeout: 20 * time.Second,
ErrorLimit: 10,
BufferSize: 512,
}
}
func (g *GTerm) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.String(), "/ws") {
g.WS(w, r)
} else {
g.App(w, r)
}
}
type App struct {
AppName string
}
func (g *GTerm) App(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
tmpl.Execute(w, App{g.AppName})
return
}
file, err := public.FS.Open(strings.TrimPrefix(r.URL.String(), "/"))
if err != nil {
// tmpl.Execute(w, App{g.AppName})
http.NotFound(w, r)
return
}
stat, err := file.Stat()
if err != nil || stat.IsDir() {
// tmpl.Execute(w, App{g.AppName})
http.NotFound(w, r)
return
}
servePublic.ServeHTTP(w, r)
}
type NewCmdRequest struct {
Cmd string `json:"cmd"`
Envs []string `json:"envs"`
Args []string `json:"args"`
Dir string `json:"dir"`
Block bool `json:"block"`
}
func (g *GTerm) defaultCmd() *exec.Cmd {
cmd := exec.Command(g.Cmd, g.Args...)
cmd.Env = g.Envs
if g.Dir != "" && g.Dir != "." {
cmd.Dir = g.Dir
}
return cmd
}
func (g *GTerm) echo(msg string) *exec.Cmd {
cmd := exec.Command("echo", msg)
cmd.Env = g.Envs
return cmd
}
func (g *GTerm) WS(w http.ResponseWriter, r *http.Request) {
id := fmt.Sprintf("[%02d] %s", ctr(), ksrv.GetIP(r))
pool[id] = make(chan struct{}, 1)
defer func() {
close(pool[id])
delete(pool, id)
}()
l := log.With().Str("id", id).Logger()
var cmd *exec.Cmd
if newCmd := r.URL.Query().Get("cmd"); newCmd != "" {
param, _ := xorencrypt.Decrypt(newCmd, g.Salt)
req := &NewCmdRequest{}
err := json.Unmarshal([]byte(param), req)
if err != nil {
l.Error().Err(err).Str("base", newCmd).Str("decrypt", param).Msg("cmd decode failed")
cmd = g.echo("cmd decode failed")
} else {
cmd = exec.Command(req.Cmd, req.Args...)
cmd.Env = append(req.Envs, "TERM=xterm-256color")
cmd.Dir = req.Dir
if req.Block {
lock, ok := locks[newCmd]
if !ok {
locks[newCmd] = make(chan struct{}, 1)
}
if len(lock) == 1 {
cmd = g.echo("cmd already running")
} else {
l.Info().Interface("cmd", req).Msg("starting cmd")
locks[newCmd] <- struct{}{}
defer func() {
<-locks[newCmd]
close(locks[newCmd])
delete(locks, newCmd)
}()
}
}
}
} else {
cmd = g.defaultCmd()
}
upgrader := websocket.Upgrader{
HandshakeTimeout: 0,
ReadBufferSize: g.BufferSize,
WriteBufferSize: g.BufferSize,
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
l.Error().Err(err).Msg("upgrade error")
return
}
l.Info().Msg("connection established.")
tty, err := pty.Start(cmd)
if err != nil {
l.Error().Err(err).Msg("start tty error")
conn.WriteMessage(websocket.TextMessage, []byte(err.Error()))
}
defer func() {
if err := cmd.Process.Kill(); err != nil {
l.Error().Err(err).Msg("proccess kill error")
}
if _, err := cmd.Process.Wait(); err != nil {
l.Error().Err(err).Msg("proccess wait error")
}
if err := tty.Close(); err != nil {
l.Error().Err(err).Msg("tty close error")
}
if err := conn.Close(); err != nil {
l.Error().Err(err).Msg("conn close error")
}
}()
var connectionClosed bool
waiter := make(chan struct{}, 1)
// this is a keep-alive loop that ensures connection does not hang-up itself
lastPongTime := time.Now()
conn.SetPongHandler(func(msg string) error {
lastPongTime = time.Now()
return nil
})
go func() {
for {
if err := conn.WriteMessage(websocket.PingMessage, []byte("keepalive")); err != nil {
l.Warn().Msg("failed to write ping message")
return
}
time.Sleep(g.Timeout / 2)
if time.Since(lastPongTime) > g.Timeout {
l.Warn().Msg("failed to get response from ping, triggering disconnect now...")
waiter <- struct{}{}
return
}
l.Debug().Msg("received response from ping successfully")
}
}()
// tty >> xterm.js
go func() {
errorCounter := 0
for {
// consider the connection closed/errored out so that the socket handler
// can be terminated - this frees up memory so the service doesn't get
// overloaded
if errorCounter > g.ErrorLimit {
waiter <- struct{}{}
break
}
buffer := make([]byte, g.BufferSize)
readLength, err := tty.Read(buffer)
if err != nil {
l.Warn().Err(err).Msg("failed to read from tty")
if err := conn.WriteMessage(websocket.TextMessage, []byte("bye!")); err != nil {
l.Warn().Err(err).Msg("failed to send termination message from tty to xterm.js")
}
waiter <- struct{}{}
return
}
if err := conn.WriteMessage(websocket.BinaryMessage, buffer[:readLength]); err != nil {
l.Warn().Msgf("failed to send %s bytes from tty to xterm.js", readLength)
errorCounter++
continue
}
l.Debug().Msgf("sent message of size %s bytes from tty to xterm.js", readLength)
errorCounter = 0
}
}()
// tty << xterm.js
go func() {
for {
// data processing
messageType, data, err := conn.ReadMessage()
if err != nil {
if !connectionClosed {
l.Warn().Err(err).Msg("failed to get next reader")
}
return
}
dataLength := len(data)
dataBuffer := bytes.Trim(data, "\x00")
dataType, ok := WebsocketMessageType[messageType]
if !ok {
dataType = "uunknown"
}
l.Debug().Msgf("received %s (type: %v) message of size %v byte(s) from xterm.js with key sequence: %v", dataType, messageType, dataLength, dataBuffer)
// process
if dataLength == -1 { // invalid
l.Warn().Msg("failed to get the correct number of bytes read, ignoring message")
continue
}
// handle resizing
if messageType == websocket.BinaryMessage {
if dataBuffer[0] == 1 {
ttySize := &TTYSize{}
resizeMessage := bytes.Trim(dataBuffer[1:], " \n\r\t\x00\x01")
if err := json.Unmarshal(resizeMessage, ttySize); err != nil {
l.Warn().Err(err).Msgf("failed to unmarshal received resize message '%s'", string(resizeMessage))
continue
}
l.Debug().Int("rows", int(ttySize.Rows)).Int("columns", int(ttySize.Cols)).Msg("resizing tty...")
if err := pty.Setsize(tty, &pty.Winsize{
Rows: ttySize.Rows,
Cols: ttySize.Cols,
}); err != nil {
l.Warn().Err(err).Msg("failed to resize tty")
}
continue
}
}
// write to tty
bytesWritten, err := tty.Write(dataBuffer)
if err != nil {
l.Warn().Err(err).Msgf("failed to write %v bytes to tty", len(dataBuffer))
continue
}
l.Debug().Msgf("%d bytes written to tty...", bytesWritten)
}
}()
<-waiter
close(waiter)
l.Info().Msg("closing connection...")
connectionClosed = true
}
var WebsocketMessageType = map[int]string{
websocket.BinaryMessage: "binary",
websocket.TextMessage: "text",
websocket.CloseMessage: "close",
websocket.PingMessage: "ping",
websocket.PongMessage: "pong",
}
// TTYSize represents a JSON structure to be sent by the frontend
// xterm.js implementation to the xterm.js websocket handler
type TTYSize struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
X uint16 `json:"x"`
Y uint16 `json:"y"`
}

View File

@ -1 +1,47 @@
<!DOCTYPE html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="favicon.ico"><title>{{.AppName}}</title><link href="css/app.0e433876.css" rel="preload" as="style"><link href="css/chunk-vendors.78de0e90.css" rel="preload" as="style"><link href="js/app.5bd01a68.js" rel="preload" as="script"><link href="js/chunk-vendors.3d58276e.js" rel="preload" as="script"><link href="css/chunk-vendors.78de0e90.css" rel="stylesheet"><link href="css/app.0e433876.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but {{.AppName}} doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="js/chunk-vendors.3d58276e.js"></script><script src="js/app.5bd01a68.js"></script></body></html>
<!DOCTYPE html>
<html>
<head>
<title>{{.AppName}}</title>
<style>
html::-webkit-scrollbar,
body::-webkit-scrollbar,
div::-webkit-scrollbar {
display: none;
width: 0;
}
html,
body {
margin: 0;
overflow: hidden;
padding: 0;
}
div#terminal {
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
div#terminal div {
height: 100%;
}
.xterm-viewport,
.xterm-screen {
height: 100%;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="terminal"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

95
main.go
View File

@ -1,95 +0,0 @@
package main
import (
_ "embed"
"flag"
"net/http"
"os"
"os/exec"
"strings"
"github.com/creack/pty"
"github.com/gorilla/websocket"
"kumoly.io/lib/klog"
"kumoly.io/lib/ksrv"
"kumoly.io/lib/ksrv/engine"
"kumoly.io/tools/goshell/public"
)
//go:embed index.html
var index string
var tmpl *engine.Engine
var servePublic = http.FileServer(http.FS(public.FS))
var (
flagAppName string
flagAddr string
flagShell string
)
func init() {
flag.StringVar(&flagAddr, "addr", ":8000", "address to bind")
flag.StringVar(&flagShell, "shell", "bash", "the shell behind")
}
func main() {
flag.Parse()
server := ksrv.New()
mux := http.NewServeMux()
tmpl = engine.Must(engine.New("").Parse(index))
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
if r.URL.String() == "/" {
tmpl.Execute(rw, App{flagAppName})
return
}
file, err := public.FS.Open(strings.TrimPrefix(r.URL.String(), "/"))
if err != nil {
klog.Debug(err)
tmpl.Execute(rw, App{flagAppName})
return
}
stat, err := file.Stat()
if err != nil || stat.IsDir() {
klog.Debug(err)
tmpl.Execute(rw, App{flagAppName})
return
}
servePublic.ServeHTTP(rw, r)
})
server.Handle(mux).Listen(flagAddr).Serve()
}
type App struct {
AppName string
}
type windowSize struct {
Rows uint16 `json:"rows"`
Cols uint16 `json:"cols"`
X uint16
Y uint16
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func handleWebsocket(w http.ResponseWriter, r *http.Request) {
l := klog.Sub(ksrv.GetIP(r))
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
l.Error("Unable to upgrade connection, err: ", err)
return
}
cmd := exec.Command("/bin/bash", "-l")
cmd.Env = append(os.Environ(), "TERM=xterm")
pty.Winsize
}

60
main.js Normal file
View File

@ -0,0 +1,60 @@
import { Terminal } from 'xterm'
import { AttachAddon } from 'xterm-addon-attach';
import { FitAddon } from 'xterm-addon-fit';
import { SerializeAddon } from "xterm-addon-serialize";
import { Unicode11Addon } from 'xterm-addon-unicode11';
import { WebLinksAddon } from 'xterm-addon-web-links';
import 'xterm/css/xterm.css'
(function() {
const terminal = new Terminal({
// screenKeys: true,
// useStyle: true,
// cursorBlink: true,
// fullscreenWin: true,
// maximizeWin: true,
// screenReaderMode: true,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
var protocol = (location.protocol === "https:") ? "wss://" : "ws://";
var url = protocol + location.host + location.pathname + "ws" + location.search
const ws = new WebSocket(url);
const attachAddon = new AttachAddon(ws);
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(webLinksAddon);
const unicode11Addon = new Unicode11Addon();
terminal.loadAddon(unicode11Addon);
const serializeAddon = new SerializeAddon();
terminal.loadAddon(serializeAddon);
terminal.open(document.getElementById("terminal"));
ws.onclose = function(event) {
console.log(event);
terminal.write('\r\n\nconnection has been terminated from the server-side (hit refresh to restart)\n')
};
ws.onopen = function() {
terminal.loadAddon(attachAddon);
terminal._initialized = true;
terminal.focus();
setTimeout(function() {fitAddon.fit()});
document.addEventListener('keypress',(e)=>{
e.preventDefault();
})
terminal.onResize(function(event) {
var rows = event.rows;
var cols = event.cols;
var size = JSON.stringify({cols: cols, rows: rows + 1});
var send = new TextEncoder().encode("\x01" + size);
console.log('resizing to', size);
ws.send(send);
});
terminal.onTitleChange(function(event) {
console.log(event);
});
window.onresize = function() {
console.log("resize")
fitAddon.fit();
};
fitAddon.fit();
};
})();

31
make.sh Executable file
View File

@ -0,0 +1,31 @@
VERSION=$(git describe --tags --abbrev=0)
if [ $? -ne 0 ]; then VERSION=$DRONE_TAG; fi
BUILD=$(git rev-parse --short HEAD)
if [ $? -ne 0 ]; then BUILD=${DRONE_COMMIT:0:7}; fi
PROJ=gterm
DIST=dist
LDFLAGS="-ldflags \"-X main.Version=${VERSION} -X main.Build=${BUILD} -w -s\""
FAILURES=""
PLATFORMS="darwin/amd64 darwin/arm64"
PLATFORMS="$PLATFORMS linux/amd64"
PLATFORMS="$PLATFORMS linux/s390x"
for PLATFORM in $PLATFORMS; do
GOOS=${PLATFORM%/*}
GOARCH=${PLATFORM#*/}
BIN_FILENAME="${PROJ}"
CMD="CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} go build ${LDFLAGS} -o ${DIST}/${BIN_FILENAME} cmd/gterm/main.go"
echo "${CMD}"
eval $CMD || FAILURES="${FAILURES} ${PLATFORM}"
sh -c "cd ${DIST} && tar -czf ${PROJ}-${VERSION}-${GOOS}-${GOARCH}.tar.gz ${BIN_FILENAME} && rm ${BIN_FILENAME}"
done
if [[ "${FAILURES}" != "" ]]; then
echo ""
echo "${SCRIPT_NAME} failed on: ${FAILURES}"
exit 1
fi

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "gterm",
"version": "1.0.0",
"repository": "git@kumoly.io:tools/gterm.git",
"author": "Evan Chen <evanchen@kumoly.io>",
"license": "MIT",
"scripts": {
"build":"rm public/*.js public/*.css && yarn parcel build index.html"
},
"dependencies": {
"parcel": "^2.0.1",
"xterm": "^4.15.0",
"xterm-addon-attach": "^0.6.0",
"xterm-addon-fit": "^0.5.0",
"xterm-addon-serialize": "^0.6.1",
"xterm-addon-unicode11": "^0.3.0",
"xterm-addon-web-links": "^0.4.0"
},
"targets":{
"default":{
"distDir":"public",
"publicUrl":"./",
"sourceMap": false
}
}
}

31
profile Normal file
View File

@ -0,0 +1,31 @@
export LSCOLORS="gxfxcxdxbxegedabagacad"
export CLICOLOR=1
export TERM="xterm-256color"
PS1='\[\e[0;33m\]\u\[\e[0m\]@\[\e[0;32m\]\h\[\e[0m\]:\[\033[01;34m\]\w\[\033[00m\]$(git_info)\[\033[00m\]\n\[\033[1;31m\]\$ \[\033[00m\]'
# functions
function git_info {
ref=$(git symbolic-ref HEAD 2> /dev/null) || return;
# Check for uncommitted changes in the index
if ! $(git diff --quiet --ignore-submodules --cached); then
uc=" $(tput setaf 64)+"
fi
# Check for unstaged changes
if ! $(git diff-files --quiet --ignore-submodules --); then
us=" $(tput setaf 124)!"
fi
# Check for untracked files
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
ut=" $(tput setaf 166)?"
fi
# Check for stashed files
if $(git rev-parse --verify refs/stash &>/dev/null); then
st=" $(tput setaf 136)$"
fi
echo " ($(tput bold)${ref#refs/heads/}$uc$us$ut$st$(tput sgr0)$(tput setaf 254))";
# echo "(${ref#refs/heads/})";
}

View File

@ -1 +0,0 @@
.xterm{position:relative;-moz-user-select:none;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm{cursor:text}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility,.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:.5}.xterm-underline{text-decoration:underline}.xterm-strikethrough{text-decoration:line-through}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -2,5 +2,5 @@ package public
import "embed"
//go:embed js favicon.ico
//go:embed *
var FS embed.FS

View File

@ -0,0 +1 @@
.xterm{position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{border:0;height:0;left:-9999em;margin:0;opacity:0;overflow:hidden;padding:0;position:absolute;resize:none;top:0;white-space:nowrap;width:0;z-index:-5}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;bottom:0;cursor:default;left:0;overflow-y:scroll;position:absolute;right:0;top:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{left:0;position:absolute;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;left:-9999em;line-height:normal;position:absolute;top:0;visibility:hidden}.xterm{cursor:text}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility,.xterm .xterm-message{bottom:0;color:transparent;left:0;position:absolute;right:0;top:0;z-index:10}.xterm .live-region{height:1px;left:-9999px;overflow:hidden;position:absolute;width:1px}.xterm-dim{opacity:.5}.xterm-underline{text-decoration:underline}.xterm-strikethrough{text-decoration:line-through}

1
public/index.a2a226ad.js Normal file

File diff suppressed because one or more lines are too long

1
public/index.html Normal file
View File

@ -0,0 +1 @@
<!DOCTYPE html><html><head><link rel="stylesheet" href="index.6999253a.css"><title>{{.AppName}}</title><style>body::-webkit-scrollbar,div::-webkit-scrollbar,html::-webkit-scrollbar{display:none;width:0}body,html{margin:0;overflow:hidden;padding:0}div#terminal{height:100%;left:0;position:absolute;top:0;width:100%}div#terminal div{height:100%}.xterm-screen,.xterm-viewport{height:100%;margin:0;padding:0}</style></head><body> <div id="terminal"></div> <script type="module" src="index.a2a226ad.js"></script> </body></html>

View File

@ -1,2 +0,0 @@
(function(e){function t(t){for(var r,c,a=t[0],i=t[1],p=t[2],l=0,s=[];l<a.length;l++)c=a[l],Object.prototype.hasOwnProperty.call(o,c)&&o[c]&&s.push(o[c][0]),o[c]=0;for(r in i)Object.prototype.hasOwnProperty.call(i,r)&&(e[r]=i[r]);f&&f(t);while(s.length)s.shift()();return u.push.apply(u,p||[]),n()}function n(){for(var e,t=0;t<u.length;t++){for(var n=u[t],r=!0,a=1;a<n.length;a++){var i=n[a];0!==o[i]&&(r=!1)}r&&(u.splice(t--,1),e=c(c.s=n[0]))}return e}var r={},o={app:0},u=[];function c(t){if(r[t])return r[t].exports;var n=r[t]={i:t,l:!1,exports:{}};return e[t].call(n.exports,n,n.exports,c),n.l=!0,n.exports}c.m=e,c.c=r,c.d=function(e,t,n){c.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},c.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},c.t=function(e,t){if(1&t&&(e=c(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(c.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)c.d(n,r,function(t){return e[t]}.bind(null,r));return n},c.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return c.d(t,"a",t),t},c.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},c.p="";var a=window["webpackJsonp"]=window["webpackJsonp"]||[],i=a.push.bind(a);a.push=t,a=a.slice();for(var p=0;p<a.length;p++)t(a[p]);var f=i;u.push([0,"chunk-vendors"]),n()})({0:function(e,t,n){e.exports=n("56d7")},"0ae4":function(e,t,n){},"56d7":function(e,t,n){"use strict";n.r(t);n("e260"),n("e6cf"),n("cca6"),n("a79d");var r=n("7a23");function o(e,t,n,o,u,c){var a=Object(r["e"])("XTerm");return Object(r["d"])(),Object(r["b"])(a)}function u(e,t,n,o,u,c){return Object(r["d"])(),Object(r["c"])("h1",null,"TEST")}var c={},a=(n("9a49"),n("6b0d")),i=n.n(a);const p=i()(c,[["render",u]]);var f=p,l={name:"App",components:{XTerm:f}};const s=i()(l,[["render",o]]);var d=s;Object(r["a"])(d).mount("#app")},"9a49":function(e,t,n){"use strict";n("0ae4")}});
//# sourceMappingURL=app.5bd01a68.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

View File

@ -1,17 +0,0 @@
module.exports = {
root: true,
env: {
node: true
},
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended'
],
parserOptions: {
parser: 'babel-eslint'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
}

23
src/.gitignore vendored
View File

@ -1,23 +0,0 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,24 +0,0 @@
# src
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Lints and fixes files
```
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View File

@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@ -1,29 +0,0 @@
{
"name": "src",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.6.5",
"vue": "^3.0.0",
"xterm": "^4.15.0",
"xterm-addon-fit": "^0.5.0",
"xterm-addon-search": "^0.8.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0",
"sass": "^1.26.5",
"sass-loader": "^8.0.2",
"vue-cli-plugin-pug": "~2.0.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,6 +0,0 @@
package public
import "embed"
//go:embed css js favicon.ico
var FS embed.FS

View File

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>{{.AppName}}</title>
</head>
<body>
<noscript>
<strong>We're sorry but {{.AppName}} doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@ -1,17 +0,0 @@
<template lang="pug">
XTerm
</template>
<script>
import XTerm from './components/XTerm.vue'
export default {
name: 'App',
components: {
XTerm
}
}
</script>
<style lang="scss">
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -1,46 +0,0 @@
<template lang="pug">
.console#terminal
</template>
<script>
import {Terminal} from 'xterm'
import {FitAddon} from 'xterm-addon-fit'
export default {
data () {
return {
term: null,
terminalSocket: null,
fitaddon: null
}
},
mounted(){
console.log("mounted")
let terminalContainer = document.getElementById('terminal')
this.term = new Terminal()
this.fitaddon = new FitAddon()
this.term.loadAddon(this.fitaddon)
this.term.open(terminalContainer)
this.fitaddon.fit()
this.term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
console.log(this.term)
},
Unmounted () {
this.term.dispose()
},
methods:{
}
}
</script>
<style lang="scss">
@import '~xterm/css/xterm.css';
.console {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
</style>

View File

@ -1,4 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@ -1,8 +0,0 @@
module.exports = {
publicPath: "",
outputDir: "../public",
indexPath: "../index.html",
devServer: {
proxy: 'http://localhost:8000'
}
}

File diff suppressed because it is too large Load Diff

16
util.go Normal file
View File

@ -0,0 +1,16 @@
package gterm
import "sync"
var curCtr = 0
var ctrLck sync.Mutex
func ctr() int {
ctrLck.Lock()
defer ctrLck.Unlock()
if curCtr >= 99 {
curCtr = 0
}
curCtr++
return curCtr
}

4561
yarn.lock Normal file

File diff suppressed because it is too large Load Diff