├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.ZH.md ├── README.md ├── VERSION ├── audit └── audit.go ├── config └── config.go ├── container ├── container.go ├── docker │ ├── docker.go │ ├── docker_test.go │ ├── log_parser.go │ └── slave.go ├── grpc │ ├── dialer.go │ ├── grpc.go │ └── grpc_tty.go └── kube │ ├── kubectl.go │ ├── kubectl_test.go │ ├── pod_struct.go │ └── tty.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── images ├── bash.png ├── cmd.png ├── list.png ├── logs.png └── sh.png ├── js ├── dist │ └── gotty-bundle.js ├── package-lock.json ├── package.json ├── src │ ├── hterm.ts │ ├── main.ts │ ├── websocket.ts │ ├── webtty.ts │ └── xterm.ts ├── tsconfig.json ├── typings │ └── libapps │ │ └── index.d.ts └── webpack.config.js ├── main.go ├── proxy ├── pb │ ├── api.pb.go │ ├── api.proto │ └── generated.proto ├── proxy.go └── server.go ├── resources ├── control.js ├── favicon.png ├── index.css ├── index.html ├── list.css ├── list.html └── xterm_customize.css ├── route ├── asset │ ├── asset.go │ └── bindata.go ├── exec.go ├── handler.go ├── handler_atomic.go ├── id.go ├── log.go ├── route.go ├── share.go ├── slave_wrapper.go ├── types.go ├── ws.go └── ws_wrapper.go ├── run.go ├── third-part └── gotty │ └── webtty │ ├── option.go │ ├── types.go │ ├── webtty.go │ └── webtty_test.go ├── types ├── containers.go ├── tty.go └── types.go ├── util ├── id.go ├── rwc.go ├── util.go └── util_test.go └── version.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [ "*" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: '1.20' 21 | - name: Test 22 | run: make test 23 | - name: Build 24 | run: make build 25 | 26 | build-image: 27 | runs-on: ubuntu-latest 28 | needs: build 29 | if: github.ref == 'refs/heads/master' 30 | steps: 31 | - 32 | name: Login to Docker Hub 33 | uses: docker/login-action@v3 34 | with: 35 | username: ${{ vars.DOCKER_USERNAME }} 36 | password: ${{ secrets.DOCKER_PASSWORD }} 37 | - uses: actions/checkout@v3 38 | - 39 | name: Build the Docker image 40 | run: | 41 | make build 42 | make image 43 | if [[ ${{ github.ref_name }} == "master" ]]; then make push-image; fi 44 | if [[ ${{ github.ref_name }} == "develop" ]]; then make push-develop; fi 45 | if [[ ${{ github.ref_type }} == "tag" ]]; then make push-tag; fi -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | bin/* 13 | 14 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 15 | .glide/ 16 | vendor/ 17 | 18 | # from gotty 19 | static 20 | js/node_modules/* 21 | 22 | # audit file 23 | audit/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | COPY bin/container-web-tty /usr/bin 3 | EXPOSE 8080 4 | CMD [ "container-web-tty" ] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME = container-web-tty 2 | PKG = github.com/wrfly/$(NAME) 3 | BIN = bin 4 | IMAGE := wrfly/$(NAME) 5 | 6 | VERSION := $(shell cat VERSION) 7 | COMMITID := $(shell git rev-parse --short HEAD) 8 | BUILDAT := $(shell date +%Y-%m-%d) 9 | 10 | CTIMEVAR = -X main.CommitID=$(COMMITID) \ 11 | -X main.Version=$(VERSION) \ 12 | -X main.BuildAt=$(BUILDAT) 13 | GO_LDFLAGS = -ldflags "-s -w $(CTIMEVAR)" 14 | GO_LDFLAGS_STATIC = -ldflags "-w $(CTIMEVAR) -extldflags -static" 15 | 16 | export GO111MODULE=on 17 | 18 | .PHONY: build 19 | build: 20 | mkdir -p bin 21 | CGO_ENABLED=0 go build $(GO_LDFLAGS) -o $(BIN)/$(NAME) . 22 | 23 | .PHONY: test 24 | test: 25 | go test -cover -v ./... 26 | 27 | .PHONY: dev 28 | dev: asset build 29 | ./$(BIN)/$(NAME) -d 30 | 31 | .PHONY: release 32 | release: 33 | GOOS=linux GOARCH=amd64 go build $(GO_LDFLAGS) -o $(BIN)/$(NAME)_linux_amd64 . 34 | GOOS=darwin GOARCH=amd64 go build $(GO_LDFLAGS) -o $(BIN)/$(NAME)_darwin_amd64 . 35 | tar -C $(BIN) -czf $(BIN)/$(NAME)_linux_amd64.tgz $(NAME)_linux_amd64 36 | tar -C $(BIN) -czf $(BIN)/$(NAME)_darwin_amd64.tgz $(NAME)_darwin_amd64 37 | 38 | .PHONY: image 39 | image: 40 | docker build -t $(IMAGE) . 41 | 42 | .PHONY: push-image 43 | push-image: 44 | docker push $(IMAGE) 45 | 46 | .PHONY: push-develop 47 | push-develop: 48 | docker tag $(IMAGE) $(IMAGE):develop 49 | docker push $(IMAGE):develop 50 | 51 | .PHONY: push-tag 52 | push-tag: 53 | docker tag $(IMAGE) $(IMAGE):$(VERSION) 54 | docker push $(IMAGE):$(VERSION) 55 | 56 | .PHONY: api 57 | api: 58 | protoc -I proxy/pb proxy/pb/api.proto --go_out=plugins=grpc:proxy/pb/ 59 | 60 | .PHONY: proto 61 | proto: 62 | proteus proto -f /tmp -p github.com/wrfly/container-web-tty/types --verbose 63 | cp /tmp/github.com/wrfly/container-web-tty/types/generated.proto proxy/pb 64 | 65 | ## --- these stages are copied from gotty for asset building --- ## 66 | .PHONY: asset 67 | asset: clear static/js static/css static/html 68 | bindata \ 69 | -pkg $(PKG)/route/asset \ 70 | -src static/ 71 | 72 | clear: 73 | rm -rf static 74 | 75 | static: 76 | mkdir -p static 77 | 78 | static/html: static 79 | cp resources/*.html static/ 80 | cp resources/favicon.png static/favicon.png 81 | 82 | static/js: static js/dist/gotty-bundle.js 83 | mkdir -p static/js 84 | cp resources/*.js static/js/ 85 | cp js/dist/gotty-bundle.js static/js/gotty-bundle.js 86 | 87 | static/css: static js/node_modules/xterm/css/xterm.css 88 | mkdir -p static/css 89 | cp resources/*.css static/css 90 | cp js/node_modules/xterm/css/xterm.css static/css/xterm.css 91 | 92 | js/node_modules/xterm/dist/xterm.css: 93 | cd js && \ 94 | npm install 95 | 96 | # .PHONY: js/dist/gotty-bundle.js 97 | js/dist/gotty-bundle.js: js/node_modules/webpack 98 | cd js && \ 99 | `npm bin`/webpack 100 | 101 | js/node_modules/webpack: 102 | cd js && \ 103 | npm install 104 | 105 | tools: 106 | go get github.com/wrfly/bindata 107 | 108 | genOptions: 109 | @$(BIN)/$(NAME) -h | \ 110 | grep -A 100 OPTION | \ 111 | sed "s/(default.*//" | \ 112 | sed "s/\\[.*//g" -------------------------------------------------------------------------------- /README.ZH.md: -------------------------------------------------------------------------------- 1 | # Container web TTY 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/wrfly/container-web-tty)](https://goreportcard.com/report/github.com/wrfly/container-web-tty) 4 | [![Master Build Status](https://travis-ci.org/wrfly/container-web-tty.svg?branch=master)](https://travis-ci.org/wrfly/container-web-tty) 5 | [![GoDoc](https://godoc.org/github.com/wrfly/container-web-tty?status.svg)](https://godoc.org/github.com/wrfly/container-web-tty) 6 | [![license](https://img.shields.io/github/license/wrfly/container-web-tty.svg)](https://github.com/wrfly/container-web-tty/blob/master/LICENSE) 7 | [![Docker Pulls](https://img.shields.io/docker/pulls/wrfly/container-web-tty.svg)](https://hub.docker.com/r/wrfly/container-web-tty) 8 | [![MicroBadger Size](https://img.shields.io/microbadger/image-size/wrfly/container-web-tty.svg)](https://hub.docker.com/r/wrfly/container-web-tty) 9 | [![GitHub release](https://img.shields.io/github/release/wrfly/container-web-tty.svg)](https://github.com/wrfly/container-web-tty/releases) 10 | [![Github All Releases](https://img.shields.io/github/downloads/wrfly/container-web-tty/total.svg)](https://github.com/wrfly/container-web-tty/releases) 11 | 12 | [English](README.md) 13 | 14 | 当我们想进入某个容器内部的时候,通常会执行这个命令组合 `docker ps | grep xxx` && `docker exec -ti xxxx sh`, 15 | 但老这样敲也是很烦,也许你可以试一下这个项目。 16 | 17 | 虽然我很喜欢终端,但是我仍然希望有一个更好的工具来进入容器内部去做一些检查或者debug。所以我写了这个项目,它能够帮助你通过点击网页的方式 18 | 进到容器里执行命令。初版的代码是基于[yudai/gotty](https://github.com/yudai/gotty)这个项目的,感谢yudai。 19 | 20 | 后端可以对接docker或者kubectl。 21 | 22 | ## 使用 23 | 24 | 你可以从release页面下载二进制运行这个程序,但这里有一些“复制粘贴”的方法。 25 | 26 | ### 通过 docker 27 | 28 | 把`docker.sock`挂在到容器里就完事儿了 29 | 30 | ```bash 31 | docker run --rm -ti --name web-tty \ 32 | -p 8080:8080 \ 33 | -v /var/run/docker.sock:/var/run/docker.sock \ 34 | wrfly/container-web-tty 35 | ``` 36 | 37 | ### 通过 kubernetes 38 | 39 | 你需要把kubernetes的配置文件挂进去,默认是在 `$HOME/.kube/config`,然后指定一下backed的类型,也就是`kube` 40 | 41 | ```bash 42 | docker run --rm -ti --name web-tty \ 43 | -p 8080:8080 \ 44 | -e WEB_TTY_BACKEND=kube \ 45 | -e WEB_TTY_KUBE_CONFIG=/kube.config \ 46 | -v ~/.kube/config:/kube.config \ 47 | wrfly/container-web-tty 48 | ``` 49 | 50 | ### 通过 gRPC (代理模式) 51 | 52 | 当我们有很多server需要接入的时候,就可以使用这种模式把远程的`container-web-tty`们 53 | **merge** 到一起,典型的CS模式,通过gRPC通信。 54 | 55 | 在一个界面上查看多台机器上的容器。 56 | 57 | #### 远程配置 58 | 59 | 假如有两台机器 `192.168.66.1` 和 `192.168.66.2`,他们可以用如下的命令来启动`container-web-tty` 60 | 61 | ```bash 62 | docker run --rm -ti --name web-tty \ 63 | -p 8080:8080 \ 64 | -p 8090:8090 \ 65 | -e WEB_TTY_GRPC_PORT=8090 \ 66 | -e WEB_TTY_GRPC_AUTH=96ssW0rd \ 67 | -v /var/run/docker.sock:/var/run/docker.sock \ 68 | wrfly/container-web-tty 69 | ``` 70 | 71 | 注意: 72 | 73 | - 你可以通过设置 `WEB_TTY_PORT=-1` 的方式来关闭HTTPserver,拒绝一般接入 74 | - 这个 `WEB_TTY_GRPC_AUTH` key 在所有机器上必须要相同(目前) 75 | 76 | #### 本地配置 77 | 78 | ```bash 79 | docker run --rm -ti --name web-tty \ 80 | -p 8080:8080 \ 81 | -e WEB_TTY_BACKEND=grpc \ 82 | -e WEB_TTY_GRPC_AUTH=96ssW0rd \ 83 | -e WEB_TTY_GRPC_SERVERS=192.168.66.1:8090,192.168.66.2:8090 \ 84 | wrfly/container-web-tty 85 | ``` 86 | 87 | 现在你就可以通过访问 ** 来获取两台机器上所有的容器 88 | 89 | ## 快捷键 (Linux) 90 | 91 | - Cut the word before the cursor `Ctrl+w` => **You cannot do it for now** (I'll working on it for `Ctrl+Backspace`, but I know little about js) 92 | - Copy: `Ctrl+Shift+c` => `Ctrl+Insert` 93 | - Paste: `Ctrl+Shift+v` => `Shift+Insert` 94 | 95 | ## 特性 96 | 97 | - [x] 能用了 98 | - [x] 对接 docker 后端 99 | - [x] 对接 kubectl 的后端 100 | - [x] 比较好看的前端界面 101 | - [x] start|stop|restart container(docker backend only) 102 | - [x] 代理模式 (本地连接到远程机器上的容器) 103 | - [x] 认证(仅限代理模式) 104 | - [x] 超时自动断开 105 | - [x] 历史记录审计 106 | - [x] 实时共享输入输出 107 | - [x] 容器日志 108 | - [x] 自定义执行命令 109 | - [x] 通过代理连接gRPC服务器 110 | 111 | ## 效果展示 112 | 113 | 列出所有容器: 114 | 115 | ![list](images/list.png) 116 | 117 | 在选择shell的时候,优选选择bash,如果没有,就依次选择ash,sh,再没有就退出了。 118 | 119 | `/bin/sh`: 120 | 121 | 122 | 123 | `/bin/bash`: 124 | 125 | 126 | 127 | 运行指定命令: 128 | 129 | 130 | 131 | 查看容器日志: 132 | 133 | ![logs](images/logs.png) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Container web TTY 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/wrfly/container-web-tty)](https://goreportcard.com/report/github.com/wrfly/container-web-tty) 4 | [![Master Build Status](https://travis-ci.org/wrfly/container-web-tty.svg?branch=master)](https://travis-ci.org/wrfly/container-web-tty) 5 | [![GoDoc](https://godoc.org/github.com/wrfly/container-web-tty?status.svg)](https://godoc.org/github.com/wrfly/container-web-tty) 6 | [![license](https://img.shields.io/github/license/wrfly/container-web-tty.svg)](https://github.com/wrfly/container-web-tty/blob/master/LICENSE) 7 | [![Docker Pulls](https://img.shields.io/docker/pulls/wrfly/container-web-tty.svg)](https://hub.docker.com/r/wrfly/container-web-tty) 8 | [![MicroBadger Size](https://img.shields.io/microbadger/image-size/wrfly/container-web-tty.svg)](https://hub.docker.com/r/wrfly/container-web-tty) 9 | [![GitHub release](https://img.shields.io/github/release/wrfly/container-web-tty.svg)](https://github.com/wrfly/container-web-tty/releases) 10 | [![Github All Releases](https://img.shields.io/github/downloads/wrfly/container-web-tty/total.svg)](https://github.com/wrfly/container-web-tty/releases) 11 | 12 | [中文](README.ZH.md) 13 | 14 | Tired of typing `docker ps | grep xxx` && `docker exec -ti xxxx sh` ? Try me! 15 | 16 | Although I like terminal, I still want a better tool to get into the containers to do some debugging or checking. 17 | So I build this `container-web-tty`. It can help you get into the container and execute commands via a web-tty, 18 | based on [yudai/gotty](https://github.com/yudai/gotty) with some changes. 19 | 20 | Both `docker` and `kubectl` are supported. 21 | 22 | ## Usage 23 | 24 | Of cause you can run it by downloading the binary, but thare are some 25 | `Copy-and-Paste` ways. 26 | 27 | ### Using docker 28 | 29 | You can start `container-web-tty` inside a container by mounting `docker.sock`: 30 | 31 | ```bash 32 | docker run -dti --restart always --name container-web-tty \ 33 | -p 8080:8080 \ 34 | -v /var/run/docker.sock:/var/run/docker.sock \ 35 | wrfly/container-web-tty 36 | 37 | # tail logs 38 | docker logs -f container-web-tty 39 | ``` 40 | 41 | ### Using kubernetes 42 | 43 | Or you can mount the kubernetes config file: 44 | 45 | ```bash 46 | docker run -dti --restart always --name container-web-tty \ 47 | -p 8080:8080 \ 48 | -e WEB_TTY_BACKEND=kube \ 49 | -e WEB_TTY_KUBE_CONFIG=/kube.config \ 50 | -v ~/.kube/config:/kube.config \ 51 | wrfly/container-web-tty 52 | ``` 53 | 54 | ### Using local <-> remote (gRPC) 55 | 56 | You can deploy `container-web-tty` in remote servers, and connect 57 | to it via a local `container-web-tty`. They use gRPC for communication. 58 | 59 | This is useful when you cannot get the remote servers or there are more 60 | than one server that you need to connect to. 61 | 62 | #### Remote 63 | 64 | Host `192.168.66.1` and `192.168.66.2` both running: 65 | 66 | ```bash 67 | docker run -dti --restart always --name container-web-tty \ 68 | -p 8080:8080 \ 69 | -p 8090:8090 \ 70 | -e WEB_TTY_GRPC_PORT=8090 \ 71 | -e WEB_TTY_GRPC_AUTH=96ssW0rd \ 72 | -v /var/run/docker.sock:/var/run/docker.sock \ 73 | wrfly/container-web-tty 74 | ``` 75 | 76 | Notes: 77 | 78 | - You can disable the HTTP server by setting `WEB_TTY_PORT=-1` 79 | - The `WEB_TTY_GRPC_AUTH` must be the same between all hosts 80 | 81 | #### Local 82 | 83 | ```bash 84 | docker run -dti --restart always --name container-web-tty \ 85 | -p 8080:8080 \ 86 | -e WEB_TTY_BACKEND=grpc \ 87 | -e WEB_TTY_GRPC_AUTH=96ssW0rd \ 88 | -e WEB_TTY_GRPC_SERVERS=192.168.66.1:8090,192.168.66.2:8090 \ 89 | wrfly/container-web-tty 90 | ``` 91 | 92 | Now you will see all the containers of all the servers via ** 93 | 94 | ## Keyboard Shortcuts (Linux) 95 | 96 | - Cut the word before the cursor `Ctrl+w` => **You cannot do it for now** (I'll working on it for `Ctrl+Backspace`, but I know little about js) 97 | - Copy: `Ctrl+Shift+c` => `Ctrl+Insert` 98 | - Paste: `Ctrl+Shift+v` => `Shift+Insert` 99 | 100 | ## Features 101 | 102 | - [x] it works 103 | - [x] docker backend 104 | - [x] kubectl backend 105 | - [x] beautiful index 106 | - [x] support `docker ps` options 107 | - [x] start|stop|restart container(docker backend only) 108 | - [x] proxy mode (client -> server's containers) 109 | - [x] auth(only in proxy mode) 110 | - [x] TTY timeout (idle timeout) 111 | - [x] history audit (just `cat` the history logs after enable this feature) 112 | - [x] real time sharing (like screen sharing) 113 | - [x] container logs (click the container name) 114 | - [x] exec arguments (append an extra "?cmd=xxx" argument in URL) 115 | - [x] connect to gRPC servers via HTTP/Socks5 proxy 116 | 117 | ### Audit exec history and container outputs 118 | 119 | ```bash 120 | docker run -dti --restart always --name container-web-tty \ 121 | -p 8080:8080 \ 122 | -e WEB_TTY_AUDIT=true \ 123 | -v `pwd`/container-audit:/audit \ 124 | -v /var/run/docker.sock:/var/run/docker.sock \ 125 | wrfly/container-web-tty 126 | ``` 127 | 128 | After you exec some commands, you will see the inputs and outputs under the 129 | `container-audit` directory, you can use `cat` or `tail -f` to see the changes. 130 | 131 | ### Real-time sharing 132 | 133 | You can always share the container's inputs and outputs with others via the exec 134 | link, just share the `/exec/` to them! 135 | 136 | ### Collaborate 137 | 138 | ```bash 139 | docker run -dti --restart always --name container-web-tty \ 140 | -p 8080:8080 \ 141 | -e WEB_TTY_COLLABORATE=true \ 142 | -v /var/run/docker.sock:/var/run/docker.sock \ 143 | wrfly/container-web-tty 144 | ``` 145 | 146 | By enabling this feature, once you exec into the container, you can share your 147 | process with others, that means anyone got the shareable link would type the command 148 | to the tty you are working on. You can edit the same file, type the same code, in the 149 | same TTY! Just share the exec link to your friend! 150 | 151 | ## Options 152 | 153 | ```txt 154 | GLOBAL OPTIONS: 155 | --addr value server binding address (default: "0.0.0.0") 156 | --audit-dir value container audit log dir path (default: "audit") 157 | --backend value, -b value backend type, 'docker' or 'kube' or 'grpc'(remote) (default: "docker") 158 | --control-all, --ctl-a enable container control (default: false) 159 | --control-restart, --ctl-r enable container restart (default: false) 160 | --control-start, --ctl-s enable container start (default: false) 161 | --control-stop, --ctl-t enable container stop (default: false) 162 | --debug, -d debug mode (log-level=debug enable pprof) (default: false) 163 | --docker-host value docker host path (default: "/var/run/docker.sock") 164 | --docker-ps value docker ps options 165 | --enable-audit, --audit enable audit the container outputs (default: false) 166 | --enable-collaborate, --clb collaborate on the same TTY process (default: false) 167 | --grpc-auth value grpc auth token (default: "password") 168 | --grpc-port value grpc server port, -1 for disable the grpc server (default: -1) 169 | --grpc-proxy value grpc proxy address, in the format of http://127.0.0.1:8080 or socks5://127.0.0.1:1080 170 | --grpc-servers value upstream servers, for proxy mode(grpc address and port), use comma for split 171 | --help, -h show help (default: false) 172 | --idle-time value time out of an idle connection 173 | --kube-config value kube config path (default: "/home/mr/.kube/config") 174 | --port value, -p value HTTP server port, -1 for disable the HTTP server (default: 8080) 175 | --version, -v print the version (default: false) 176 | ``` 177 | 178 | ## Show-off 179 | 180 | List the containers on your machine: 181 | 182 | ![list](images/list.png) 183 | 184 | It will execute `/bin/sh` if there is no `/bin/bash` inside the container: 185 | 186 | 187 | 188 | `/bin/bash`: 189 | 190 | 191 | 192 | Run custom command: 193 | 194 | 195 | 196 | Get container logs: 197 | 198 | ![logs](images/logs.png) -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.1.11 2 | -------------------------------------------------------------------------------- /audit/audit.go: -------------------------------------------------------------------------------- 1 | package audit 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path" 9 | "strings" 10 | "time" 11 | 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type LogOpts struct { 16 | Dir, ContainerID, ClientIP string 17 | } 18 | 19 | func LogTo(ctx context.Context, r io.Reader, opts LogOpts) { 20 | logDir := opts.Dir 21 | if !strings.HasPrefix(logDir, "/") { 22 | pwd, err := os.Getwd() 23 | if err != nil { 24 | logrus.Errorf("audit get pwd error: %s", err) 25 | return 26 | } 27 | logDir = path.Join(pwd, logDir) 28 | } 29 | 30 | logDir = path.Join(logDir, opts.ContainerID[:12]) 31 | _, err := os.Stat(logDir) 32 | if os.IsNotExist(err) { 33 | logrus.Debugf("create dir %s", logDir) 34 | if err := os.MkdirAll(logDir, 0755); err != nil { 35 | logrus.Errorf("mkdir error: %s", err) 36 | return 37 | } 38 | } 39 | fPath := path.Join(logDir, fmt.Sprintf("%s-%d.log", 40 | strings.Split(opts.ClientIP, ":")[0], time.Now().Unix()), 41 | ) 42 | 43 | f, err := os.Create(fPath) 44 | if err != nil { 45 | logrus.Errorf("audit create file [%s] error: %s", fPath, err) 46 | return 47 | } 48 | defer f.Close() 49 | 50 | buff := make([]byte, 2048) 51 | var start int64 52 | for ctx.Err() == nil { 53 | n, err := r.Read(buff) 54 | if err != nil { 55 | if err == io.EOF { 56 | return 57 | } 58 | logrus.Errorf("audit read container error: %s", err) 59 | return 60 | } 61 | 62 | _, err = f.WriteAt(buff[:n], start) 63 | if err != nil { 64 | logrus.Errorf("audit write file error: %s", err) 65 | return 66 | } 67 | start += int64(n) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | var SHELL_LIST = [...]string{ 6 | "/bin/bash", 7 | "/bin/ash", 8 | "/bin/sh", 9 | } 10 | 11 | type DockerConfig struct { 12 | DockerHost string // default is /var/run/docker.sock 13 | PsOptions string 14 | } 15 | 16 | type KubeConfig struct { 17 | ConfigPath string // normally is $HOME/.kube/config 18 | } 19 | 20 | type GRPCConfig struct { 21 | Servers []string 22 | Auth string 23 | Proxy string // http or socks5 24 | } 25 | 26 | type BackendConfig struct { 27 | Type string // docker or kubectl (for now) 28 | Docker DockerConfig 29 | Kube KubeConfig 30 | GRPC GRPCConfig 31 | } 32 | 33 | type ControlConfig struct { 34 | Enable bool 35 | All bool 36 | Start bool 37 | Stop bool 38 | Restart bool 39 | } 40 | 41 | type ServerConfig struct { 42 | Address string 43 | Port int 44 | Base string 45 | GrpcPort int 46 | IdleTime time.Duration 47 | 48 | Credential string 49 | EnableReconnect bool 50 | ReconnectTime int 51 | MaxConnection int 52 | WSOrigin string 53 | Term string `default:"xterm"` 54 | ShowLocation bool 55 | Collaborate bool 56 | 57 | // audit 58 | EnableAudit bool 59 | AuditLogDir string `default:"log"` 60 | 61 | Control ControlConfig 62 | 63 | // EnableBasicAuth bool `default:"false"` 64 | // Once bool `default:"false"` 65 | // TitleVariables map[string]interface{} 66 | } 67 | 68 | type Config struct { 69 | Debug bool 70 | Backend BackendConfig 71 | Server ServerConfig 72 | } 73 | 74 | func New() *Config { 75 | return &Config{ 76 | Backend: BackendConfig{ 77 | Docker: DockerConfig{}, 78 | Kube: KubeConfig{}, 79 | GRPC: GRPCConfig{ 80 | Servers: []string{}, 81 | }, 82 | }, 83 | Server: ServerConfig{ 84 | Control: ControlConfig{}, 85 | }, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /container/container.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/wrfly/container-web-tty/config" 9 | "github.com/wrfly/container-web-tty/container/docker" 10 | "github.com/wrfly/container-web-tty/container/grpc" 11 | "github.com/wrfly/container-web-tty/container/kube" 12 | "github.com/wrfly/container-web-tty/types" 13 | ) 14 | 15 | // Cli is a docker backend client 16 | type Cli interface { 17 | // GetInfo of a container 18 | GetInfo(ctx context.Context, containerID string) types.Container 19 | // List all containers 20 | List(context.Context) []types.Container 21 | Start(ctx context.Context, containerID string) error 22 | Stop(ctx context.Context, containerID string) error 23 | Restart(ctx context.Context, containerID string) error 24 | // exec into container 25 | Exec(ctx context.Context, container types.Container) (types.TTY, error) 26 | // close the connections 27 | Close() error 28 | // read logs 29 | Logs(ctx context.Context, opts types.LogOptions) (io.ReadCloser, error) 30 | } 31 | 32 | // NewCliBackend returns the client backend 33 | func NewCliBackend(conf config.BackendConfig) (cli Cli, err error) { 34 | switch conf.Type { 35 | case "docker": 36 | cli, err = docker.NewCli(conf.Docker) 37 | case "kube": 38 | cli, err = kube.NewCli(conf.Kube) 39 | case "grpc": 40 | cli, err = grpc.NewCli(conf.GRPC) 41 | default: 42 | err = fmt.Errorf("unknown backend type %s", conf.Type) 43 | } 44 | 45 | return 46 | } 47 | -------------------------------------------------------------------------------- /container/docker/docker.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | apiTypes "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/container" 13 | "github.com/docker/docker/api/types/filters" 14 | "github.com/docker/docker/client" 15 | "github.com/sirupsen/logrus" 16 | 17 | "github.com/wrfly/container-web-tty/config" 18 | "github.com/wrfly/container-web-tty/types" 19 | ) 20 | 21 | type DockerCli struct { 22 | cli *client.Client 23 | containers *types.Containers 24 | listOptions apiTypes.ContainerListOptions 25 | lastList time.Time 26 | } 27 | 28 | func NewCli(conf config.DockerConfig) (*DockerCli, error) { 29 | host := conf.DockerHost 30 | if host[:1] == "/" { 31 | host = "unix://" + host 32 | } else if !strings.HasPrefix(host, "tcp://") { 33 | host = "tcp://" + host 34 | } 35 | 36 | logrus.Infof("Docker connecting to %s", host) 37 | 38 | cli, err := client.NewClientWithOpts(client.FromEnv) 39 | if err != nil { 40 | logrus.Errorf("create new docker client error: %s", err) 41 | return nil, err 42 | } 43 | 44 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 45 | defer cancel() 46 | 47 | listOptions, err := buildListOptions(conf.PsOptions) 48 | if err != nil { 49 | return nil, fmt.Errorf("build ps options error: %s", err) 50 | } 51 | logrus.Debugf("list options: %+v", listOptions) 52 | 53 | ping, err := cli.Ping(ctx) 54 | if err != nil { 55 | return nil, err 56 | } 57 | logrus.Infof("New docker client: API [%s]", ping.APIVersion) 58 | dockerCli := &DockerCli{ 59 | cli: cli, 60 | containers: &types.Containers{}, 61 | listOptions: listOptions, 62 | } 63 | logrus.Infof("Warm up containers info...") 64 | 65 | // when docker restarted, should restart the program as well 66 | // since the socket file is gone (as the restart of docker daemon) 67 | // see #30 68 | go func() { 69 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 70 | dockerCli.listContainers(ctx, true) 71 | cancel() 72 | 73 | dockerCli.watchEvents() // will block here 74 | logrus.Fatal("lost connection to docker daemon") 75 | }() 76 | 77 | return dockerCli, nil 78 | } 79 | 80 | func getContainerIP(networkSettings interface{}) (ips []string) { 81 | defer func() { 82 | if len(ips) == 0 { 83 | ips = []string{"null"} 84 | } 85 | }() 86 | 87 | if networkSettings == nil { 88 | return 89 | } 90 | switch networkSettings.(type) { 91 | case *apiTypes.SummaryNetworkSettings: 92 | network := networkSettings.(*apiTypes.SummaryNetworkSettings) 93 | for net := range network.Networks { 94 | if network.Networks[net] == nil { 95 | continue 96 | } 97 | ips = append(ips, network.Networks[net].IPAddress) 98 | } 99 | case *apiTypes.NetworkSettings: 100 | network := networkSettings.(*apiTypes.NetworkSettings) 101 | for net := range network.Networks { 102 | if network.Networks[net] == nil { 103 | continue 104 | } 105 | ips = append(ips, network.Networks[net].IPAddress) 106 | } 107 | } 108 | 109 | return 110 | } 111 | 112 | func (docker *DockerCli) watchEvents() { 113 | eventChan, errChan := docker.cli.Events(context.Background(), apiTypes.EventsOptions{}) 114 | 115 | go func() { 116 | for event := range eventChan { 117 | if event.Type != "container" { 118 | continue 119 | } 120 | logrus.Debugf("container event: %+v", event) 121 | switch event.Action { 122 | case "start", "destroy": 123 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) 124 | docker.listContainers(ctx, true) 125 | cancel() 126 | } 127 | } 128 | }() 129 | 130 | logrus.Errorf("docker cli watch events error: %s", <-errChan) 131 | } 132 | 133 | func (docker *DockerCli) GetInfo(ctx context.Context, cid string) types.Container { 134 | if docker.containers.Len() == 0 { 135 | logrus.Debugf("zero containers, get cid %s", cid) 136 | docker.List(ctx) 137 | } 138 | 139 | // find in containers 140 | if container := docker.containers.Find(cid); container.ID != "" { 141 | if container.Shell == "" { 142 | shell := docker.getShell(ctx, cid) 143 | container.Shell = shell 144 | docker.containers.SetShell(cid, shell) 145 | } 146 | logrus.Debugf("found valid container: %s (%s)", container.ID, container.Shell) 147 | return container 148 | } 149 | 150 | // didn't get this container, this is rarelly happens 151 | cjson, err := docker.cli.ContainerInspect(ctx, cid) 152 | if err != nil { 153 | logrus.Errorf("inspect container %s error: %s", cid, err) 154 | return types.Container{} 155 | } 156 | 157 | c := docker.convertCjsonToContainre(cjson) 158 | if c.ID != "" { 159 | docker.containers.Append(c) 160 | } 161 | return c 162 | } 163 | 164 | func (docker *DockerCli) convertCjsonToContainre(cjson apiTypes.ContainerJSON) types.Container { 165 | if cjson.Config == nil { 166 | // WTF? 167 | return types.Container{} 168 | } 169 | 170 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) 171 | defer cancel() 172 | shell := docker.getShell(ctx, cjson.ID) 173 | 174 | c := types.Container{ 175 | ID: cjson.ID, 176 | Name: cjson.Name, 177 | Image: cjson.Image, 178 | Command: fmt.Sprintf("%s", cjson.Config.Cmd), 179 | IPs: getContainerIP(cjson.NetworkSettings), 180 | Status: cjson.State.Status, 181 | State: cjson.State.Status, 182 | Shell: shell, 183 | } 184 | 185 | return c 186 | } 187 | 188 | func (docker *DockerCli) listContainers(ctx context.Context, force bool) []types.Container { 189 | if time.Now().Sub(docker.lastList) < time.Minute && !force { 190 | return docker.containers.List() 191 | } 192 | 193 | start := time.Now() 194 | logrus.Debug("list conatiners") 195 | cs, err := docker.cli.ContainerList(ctx, docker.listOptions) 196 | if err != nil { 197 | logrus.Errorf("list containers eror: %s", err) 198 | return nil 199 | } 200 | 201 | containers := make([]types.Container, len(cs)) 202 | var ( 203 | ips []string 204 | shell string 205 | ) 206 | for i, container := range cs { 207 | if old := docker.containers.Find(container.ID); old.ID != "" { 208 | shell = old.Shell 209 | ips = old.IPs 210 | } else { 211 | ips = getContainerIP(container.NetworkSettings) 212 | } 213 | containers[i] = types.Container{ 214 | ID: container.ID, 215 | Name: container.Names[0][1:], 216 | Image: container.Image, 217 | Command: container.Command, 218 | IPs: ips, 219 | Status: container.Status, 220 | State: container.State, 221 | Shell: shell, 222 | } 223 | } 224 | 225 | docker.containers.Set(containers) 226 | 227 | docker.lastList = time.Now() 228 | logrus.Debugf("list %d containers, use %s", len(containers), time.Now().Sub(start)) 229 | return containers 230 | } 231 | 232 | func (docker *DockerCli) List(ctx context.Context) []types.Container { 233 | return docker.listContainers(ctx, false) 234 | } 235 | 236 | func (docker *DockerCli) exist(ctx context.Context, cid, path string) bool { 237 | _, err := docker.cli.ContainerStatPath(ctx, cid, path) 238 | if err != nil { 239 | return false 240 | } 241 | return true 242 | } 243 | 244 | func (docker *DockerCli) getShell(ctx context.Context, cid string) string { 245 | for _, sh := range config.SHELL_LIST { 246 | if docker.exist(ctx, cid, sh) { 247 | logrus.Debugf("container [%s] use [%s]", cid, sh) 248 | return sh 249 | } 250 | } 251 | // generally it won't come so far 252 | return "" 253 | } 254 | 255 | func (docker *DockerCli) Start(ctx context.Context, cid string) error { 256 | return docker.cli.ContainerStart(ctx, cid, apiTypes.ContainerStartOptions{}) 257 | } 258 | 259 | func (docker *DockerCli) Stop(ctx context.Context, cid string) error { 260 | return docker.cli.ContainerStop(ctx, cid, container.StopOptions{}) 261 | } 262 | 263 | func (docker *DockerCli) Restart(ctx context.Context, cid string) error { 264 | // restart immediately 265 | return docker.cli.ContainerRestart(ctx, cid, container.StopOptions{}) 266 | } 267 | 268 | func buildListOptions(options string) (apiTypes.ContainerListOptions, error) { 269 | // ["-a", "-f", "key=val"] 270 | // https://docs.docker.com/engine/reference/commandline/ps/#filtering 271 | listOptions := apiTypes.ContainerListOptions{} 272 | args := strings.Split(options, " ") 273 | for i, arg := range args { 274 | switch arg { 275 | case "-a", "--all": 276 | listOptions.All = true 277 | case "-f", "--filter": 278 | if i+1 < len(arg) { 279 | f := args[i+1] 280 | kv := strings.Split(f, "=") 281 | if len(kv) != 2 { 282 | return listOptions, fmt.Errorf("bad filter '%s'", f) 283 | } 284 | if listOptions.Filters.Len() == 0 { 285 | listOptions.Filters = filters.NewArgs() 286 | } 287 | listOptions.Filters.Add(kv[0], kv[1]) 288 | } 289 | case "-n", "--last": 290 | if i+1 < len(arg) { 291 | f := args[i+1] 292 | intF, err := strconv.Atoi(f) 293 | if err != nil { 294 | return listOptions, err 295 | } 296 | listOptions.Limit = intF 297 | } 298 | case "-l", "--latest": 299 | listOptions.Latest = true 300 | } 301 | } 302 | return listOptions, nil 303 | } 304 | 305 | func (docker *DockerCli) Exec(ctx context.Context, container types.Container) (types.TTY, error) { 306 | cmds := []string{container.Shell} 307 | opts := container.Exec 308 | if cmd := opts.Cmd; cmd != "" { 309 | cmds = append(cmds, "-c") 310 | cmds = append(cmds, fmt.Sprintf("\"\"%s\"\"", cmd)) 311 | } 312 | logrus.Debugf("exec cmd: %v", cmds) 313 | 314 | execConfig := apiTypes.ExecConfig{ 315 | AttachStdin: true, 316 | AttachStderr: true, 317 | AttachStdout: true, 318 | Tty: true, 319 | Privileged: opts.Privileged, 320 | Cmd: cmds, 321 | Env: []string{"HISTCONTROL=ignoredups", "TERM=xterm"}, 322 | } 323 | if opts.User != "" { 324 | execConfig.User = opts.User 325 | } 326 | if opts.Env != "" { 327 | execConfig.Env = append(execConfig.Env, 328 | strings.Split(opts.Env, " ")...) 329 | } 330 | 331 | response, err := docker.cli.ContainerExecCreate(ctx, container.ID, execConfig) 332 | if err != nil { 333 | return nil, err 334 | } 335 | 336 | execID := response.ID 337 | if execID == "" { 338 | return nil, fmt.Errorf("exec ID empty") 339 | } 340 | 341 | execCheck := apiTypes.ExecStartCheck{ 342 | Tty: true, 343 | } 344 | resp, err := docker.cli.ContainerExecAttach(ctx, execID, execCheck) 345 | if err != nil { 346 | return nil, err 347 | } 348 | 349 | resizeFunc := func(width int, height int) error { 350 | return docker.cli.ContainerExecResize(ctx, execID, 351 | apiTypes.ResizeOptions{ 352 | Width: uint(width), 353 | Height: uint(height), 354 | }) 355 | } 356 | 357 | return newExecInjector(resp, resizeFunc), nil 358 | } 359 | 360 | func (docker *DockerCli) Close() error { 361 | return docker.cli.Close() 362 | } 363 | 364 | func (docker *DockerCli) Logs(ctx context.Context, opts types.LogOptions) (io.ReadCloser, error) { 365 | rc, err := docker.cli.ContainerLogs(ctx, opts.ID, apiTypes.ContainerLogsOptions{ 366 | ShowStderr: true, 367 | ShowStdout: true, 368 | Follow: opts.Follow, 369 | Tail: opts.Tail, 370 | }) 371 | return parseContainerLog(rc), err 372 | } 373 | -------------------------------------------------------------------------------- /container/docker/docker_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/wrfly/container-web-tty/config" 8 | ) 9 | 10 | func TestDocker(t *testing.T) { 11 | ctx := context.Background() 12 | dockerConf := config.DockerConfig{ 13 | DockerHost: "/var/run/docker.sock", 14 | } 15 | t.Run("test new docker client", func(t *testing.T) { 16 | cli, err := NewCli(dockerConf) 17 | if err != nil { 18 | t.Error(err) 19 | } 20 | for _, c := range cli.List(ctx) { 21 | t.Logf("got container: %s", c.ID) 22 | } 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /container/docker/log_parser.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | ) 8 | 9 | type logReadCloser struct { 10 | io.Closer 11 | 12 | docker io.ReadCloser 13 | 14 | bsLeft int64 15 | 16 | prevHeader []byte 17 | } 18 | 19 | func _makeLine(bs []byte) []byte { 20 | if len(bs) == 0 { 21 | return nil 22 | } 23 | 24 | line := make([]byte, len(bs), len(bs)+1) 25 | copy(line, bs) 26 | 27 | if line[len(line)-1] == '\n' { 28 | line = append(line, '\r') 29 | } 30 | return line 31 | } 32 | 33 | // Read docker stream logs 34 | // https://ahmet.im/blog/docker-logs-api-binary-format-explained/ 35 | func (rc *logReadCloser) Read(targetBytes []byte) (int, error) { 36 | n, err := rc.docker.Read(targetBytes) 37 | if err != nil { 38 | return n, err 39 | } 40 | 41 | // FIXME: quick and dirty solution 42 | // need to check the API version 43 | if n >= 4 { 44 | if targetBytes[1] != 0 || targetBytes[2] != 0 || targetBytes[3] != 0 { 45 | return n, nil 46 | } 47 | } 48 | 49 | p := make([]byte, len(targetBytes)) 50 | copy(p, targetBytes) 51 | 52 | // docker log format 53 | var ( 54 | msgLen int64 55 | start int64 56 | ) 57 | bs := make([]byte, 0, n) 58 | 59 | if len(rc.prevHeader) != 0 { 60 | p = append(rc.prevHeader, p...) // append previous header 61 | n = len(p) 62 | rc.prevHeader = nil 63 | } else if rc.bsLeft > 0 { 64 | line := _makeLine(p[:rc.bsLeft]) 65 | bs = append(bs, line...) 66 | start = rc.bsLeft // reset line start index 67 | rc.bsLeft = 0 // reset left 68 | } 69 | 70 | for { 71 | if start+8 > int64(n) { 72 | rc.prevHeader = make([]byte, len(p[start:int64(n)])) 73 | copy(rc.prevHeader, p[start:int64(n)]) 74 | break 75 | } 76 | 77 | header := p[start : start+8] 78 | if p[start] != 1 && p[start] != 2 { 79 | break 80 | } 81 | 82 | lenHex := fmt.Sprintf("%x", header[4:]) 83 | msgLen, err = strconv.ParseInt(lenHex, 16, 64) 84 | if err != nil { 85 | return 0, err 86 | } 87 | 88 | start += 8 // move to msg beginning 89 | 90 | if start+msgLen > int64(n) { 91 | line := _makeLine(p[start:]) // append left bytes 92 | bs = append(bs, line...) 93 | rc.bsLeft = msgLen - (int64(n) - start) 94 | break 95 | } 96 | 97 | line := _makeLine(p[start : start+msgLen]) 98 | bs = append(bs, line...) 99 | start += msgLen // move start position to next 100 | } 101 | 102 | return copy(targetBytes, bs), nil 103 | } 104 | 105 | func parseContainerLog(rc io.ReadCloser) io.ReadCloser { 106 | if rc == nil { 107 | return nil 108 | } 109 | return &logReadCloser{ 110 | Closer: rc, 111 | docker: rc, 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /container/docker/slave.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "time" 5 | 6 | apiTypes "github.com/docker/docker/api/types" 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // execInjector implement webtty.Slave 11 | type execInjector struct { 12 | hResp apiTypes.HijackedResponse 13 | resize resizeFunction 14 | activeChan chan struct{} 15 | } 16 | 17 | type resizeFunction func(width int, height int) error 18 | 19 | func newExecInjector(resp apiTypes.HijackedResponse, resize resizeFunction) *execInjector { 20 | return &execInjector{ 21 | hResp: resp, 22 | resize: resize, 23 | activeChan: make(chan struct{}, 5), 24 | } 25 | } 26 | 27 | func (enj *execInjector) Read(p []byte) (n int, err error) { 28 | go func() { 29 | if len(enj.activeChan) != 0 { 30 | return 31 | } 32 | enj.activeChan <- struct{}{} 33 | }() 34 | // logrus.Debugf("output: %s\n", p) 35 | return enj.hResp.Reader.Read(p) 36 | } 37 | 38 | func (enj *execInjector) Write(p []byte) (n int, err error) { 39 | logrus.Debugf("docker input: %v\n", p) 40 | return enj.hResp.Conn.Write(p) 41 | } 42 | 43 | func (enj *execInjector) Exit() error { 44 | enj.Write([]byte{3, 13, 4, 13}) // ^C, ^D, enter 45 | close(enj.activeChan) 46 | return enj.hResp.Conn.Close() 47 | } 48 | 49 | func (enj *execInjector) ActiveChan() <-chan struct{} { 50 | return enj.activeChan 51 | } 52 | 53 | func (enj *execInjector) WindowTitleVariables() map[string]interface{} { 54 | return map[string]interface{}{} 55 | } 56 | 57 | func (enj *execInjector) ResizeTerminal(width int, height int) (err error) { 58 | // since the process may not up so fast, give it 150ms 59 | // retry 3 times 60 | for i := 0; i < 3; i++ { 61 | if err = enj.resize(width, height); err == nil { 62 | return 63 | } 64 | time.Sleep(time.Millisecond * 50) 65 | } 66 | return 67 | } 68 | -------------------------------------------------------------------------------- /container/grpc/dialer.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/elazarl/goproxy" 10 | "github.com/sirupsen/logrus" 11 | "golang.org/x/net/proxy" 12 | "google.golang.org/grpc" 13 | ) 14 | 15 | const ( 16 | pingTimeout = time.Second 17 | testDialAddr = "ipinfo.io:80" 18 | ) 19 | 20 | type dialer struct { 21 | socksD proxy.Dialer 22 | proxyD dialFunc 23 | } 24 | 25 | func (d *dialer) Dial(network, addr string) (net.Conn, error) { 26 | if d.socksD != nil { 27 | return d.socksD.Dial(network, addr) 28 | } 29 | return d.proxyD(network, addr) 30 | } 31 | 32 | type dialFunc func(network, address string) (net.Conn, error) 33 | 34 | func newDialOption(proxyStr string) (grpc.DialOption, error) { 35 | u, err := url.Parse(proxyStr) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | var grpcDialer func(addr string, t time.Duration) (net.Conn, error) 41 | 42 | switch u.Scheme { 43 | case "socks5": 44 | addr := fmt.Sprintf("%s", u.Host) 45 | auth := &proxy.Auth{} 46 | if user := u.User; user != nil { 47 | auth.User = user.Username() 48 | auth.Password, _ = user.Password() 49 | } 50 | socksDialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if _, err := dialWithTimeout(&dialer{socksD: socksDialer}, 56 | testDialAddr, pingTimeout); err != nil { 57 | return nil, err 58 | } 59 | grpcDialer = func(addr string, t time.Duration) (net.Conn, error) { 60 | return dialWithTimeout(&dialer{socksD: socksDialer}, addr, t) 61 | } 62 | case "http", "https": 63 | p := goproxy.NewProxyHttpServer() 64 | p.Logger = logrus.StandardLogger() 65 | httpDialer := p.NewConnectDialToProxy(proxyStr) 66 | 67 | if _, err := dialWithTimeout(&dialer{proxyD: httpDialer}, 68 | testDialAddr, pingTimeout); err != nil { 69 | return nil, err 70 | } 71 | 72 | grpcDialer = func(addr string, t time.Duration) (net.Conn, error) { 73 | return dialWithTimeout(&dialer{proxyD: httpDialer}, addr, t) 74 | } 75 | default: 76 | return nil, fmt.Errorf("unsupport scheme %s", u.Scheme) 77 | } 78 | 79 | return grpc.WithDialer(grpcDialer), nil 80 | } 81 | 82 | func dialWithTimeout(dialer proxy.Dialer, addr string, 83 | timeout time.Duration) (net.Conn, error) { 84 | timer := time.NewTimer(timeout) 85 | defer timer.Stop() 86 | 87 | connChan := make(chan net.Conn) 88 | 89 | go func() { 90 | conn, err := dialer.Dial("tcp", addr) 91 | if err == nil { 92 | connChan <- conn 93 | return 94 | } 95 | logrus.Errorf("dial to %s error: %s", addr, err) 96 | }() 97 | 98 | select { 99 | case <-timer.C: 100 | return nil, fmt.Errorf("dial %s timeout (%s)", addr, timeout) 101 | case c := <-connChan: 102 | return c, nil 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /container/grpc/grpc.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/sirupsen/logrus" 9 | "google.golang.org/grpc" 10 | "google.golang.org/grpc/connectivity" 11 | 12 | "github.com/wrfly/container-web-tty/config" 13 | pb "github.com/wrfly/container-web-tty/proxy/pb" 14 | "github.com/wrfly/container-web-tty/types" 15 | "github.com/wrfly/container-web-tty/util" 16 | ) 17 | 18 | type grpcCli struct { 19 | addr, auth string 20 | 21 | conn *grpc.ClientConn 22 | client pb.ContainerServerClient 23 | } 24 | 25 | func (g *grpcCli) close() error { 26 | return g.conn.Close() 27 | } 28 | 29 | func (g *grpcCli) waitForAvaliable(ctx context.Context) { 30 | g.conn.WaitForStateChange(ctx, connectivity.TransientFailure) 31 | } 32 | 33 | func (g *grpcCli) alive() bool { 34 | pong, err := g.client.Ping(context.Background(), &pb.Empty{Auth: g.auth}) 35 | if err != nil { 36 | logrus.Errorf("connot connect to [%s], %s", g.addr, err) 37 | return false 38 | } 39 | logrus.Debugf("pong from %s: %s", g.addr, pong.GetMsg()) 40 | return true 41 | } 42 | 43 | func (g *grpcCli) state() connectivity.State { 44 | return g.conn.GetState() 45 | } 46 | 47 | // GrpcCli connect to the remote server 48 | type GrpcCli struct { 49 | servers []string 50 | auth string 51 | clients map[string]grpcCli 52 | containers *types.Containers 53 | } 54 | 55 | // NewCli returns the GrpcCli 56 | func NewCli(conf config.GRPCConfig) (*GrpcCli, error) { 57 | logrus.Infof("New gRPC client connect to %v with auth [%s]", 58 | conf.Servers, conf.Auth) 59 | gCli := &GrpcCli{ 60 | servers: conf.Servers, 61 | auth: conf.Auth, 62 | clients: make(map[string]grpcCli, len(conf.Servers)), 63 | containers: new(types.Containers), 64 | } 65 | 66 | var opts []grpc.DialOption 67 | 68 | // add a proxy 69 | if conf.Proxy != "" { 70 | logrus.Infof("setting grpc proxy with %s", conf.Proxy) 71 | dialerOption, err := newDialOption(conf.Proxy) 72 | if err != nil { 73 | return nil, fmt.Errorf("create proxy dialer error: %s", err) 74 | } 75 | opts = append(opts, dialerOption) 76 | } 77 | /* 78 | if *tls { 79 | if *caFile == "" { 80 | *caFile = testdata.Path("ca.pem") 81 | } 82 | creds, err := credentials.NewClientTLSFromFile(*caFile, *serverHostOverride) 83 | if err != nil { 84 | log.Fatalf("Failed to create TLS credentials %v", err) 85 | } 86 | opts = append(opts, grpc.WithTransportCredentials(creds)) 87 | } 88 | */ 89 | opts = append(opts, grpc.WithInsecure()) 90 | for _, serverAddr := range conf.Servers { 91 | conn, err := grpc.Dial(serverAddr, opts...) 92 | if err != nil { 93 | logrus.Errorf("fail to dial: %v", err) 94 | continue 95 | } 96 | 97 | gCli.clients[serverAddr] = grpcCli{ 98 | auth: conf.Auth, 99 | addr: serverAddr, 100 | conn: conn, 101 | client: pb.NewContainerServerClient(conn), 102 | } 103 | } 104 | 105 | delLists := make([]string, 0, len(gCli.clients)) 106 | for addr, cli := range gCli.clients { 107 | if cli.alive() { 108 | continue 109 | } 110 | delLists = append(delLists, addr) 111 | } 112 | for _, addr := range delLists { 113 | logrus.Infof("connot ping remote server %s, remove it from clients", addr) 114 | delete(gCli.clients, addr) 115 | } 116 | 117 | return gCli, nil 118 | } 119 | 120 | func (gCli GrpcCli) GetInfo(ctx context.Context, cid string) types.Container { 121 | if gCli.containers.Len() == 0 { 122 | logrus.Debugf("zero containers, get cid %s", cid) 123 | gCli.List(ctx) 124 | } 125 | 126 | container := gCli.containers.Find(cid) 127 | if container.ID == "" { 128 | logrus.Errorf("no such container: %s", cid) 129 | return types.Container{} 130 | } 131 | if container.Shell != "" { 132 | logrus.Debugf("found valid container: %s (%s)", container.ID, container.Shell) 133 | return container 134 | } 135 | 136 | remoteAddr := container.LocServer 137 | remoteClient, exist := gCli.clients[remoteAddr] 138 | if !exist { 139 | logrus.Errorf("no remote client: %s", remoteAddr) 140 | return types.Container{} 141 | } 142 | pbContainer, err := remoteClient.client.GetInfo(ctx, 143 | &pb.ContainerID{Id: cid, Auth: gCli.auth}) 144 | if err != nil { 145 | logrus.Errorf("grpc get container error: %s", err) 146 | return types.Container{} 147 | } 148 | gCli.containers.SetShell(cid, pbContainer.GetShell()) 149 | return util.ConvertPbContainer(pbContainer) 150 | } 151 | 152 | func (gCli GrpcCli) List(ctx context.Context) []types.Container { 153 | allContainers := make([]types.Container, 0) 154 | containerIDMap := make(map[string]bool, 0) 155 | 156 | for addr, cli := range gCli.clients { 157 | if !cli.alive() { 158 | logrus.Warnf("remote server %s is not ready: %s", addr, cli.state()) 159 | continue 160 | } 161 | cs, err := cli.client.List(ctx, &pb.Empty{Auth: gCli.auth}) 162 | if err != nil { 163 | logrus.Errorf("get container info error: %s", err) 164 | continue 165 | } 166 | for _, c := range cs.Cs { 167 | c.LocServer = addr 168 | if !containerIDMap[c.Id] { 169 | allContainers = append(allContainers, 170 | util.ConvertPbContainer(c)) 171 | containerIDMap[c.Id] = true 172 | } 173 | } 174 | } 175 | 176 | gCli.containers.Set(allContainers) 177 | logrus.Debugf("list %d containers", len(allContainers)) 178 | 179 | return allContainers 180 | } 181 | 182 | func (gCli GrpcCli) containerAction(ctx context.Context, action, containerID string) error { 183 | info := gCli.containers.Find(containerID) 184 | if info.ID == "" { 185 | return fmt.Errorf("container not found") 186 | } 187 | if cli, exist := gCli.clients[info.LocServer]; exist { 188 | var err1 *pb.Err 189 | var err2 error 190 | pbCID := &pb.ContainerID{ 191 | Id: containerID, 192 | Auth: gCli.auth, 193 | } 194 | switch action { 195 | case "start": 196 | err1, err2 = cli.client.Start(ctx, pbCID) 197 | case "stop": 198 | err1, err2 = cli.client.Stop(ctx, pbCID) 199 | case "restart": 200 | err1, err2 = cli.client.Restart(ctx, pbCID) 201 | default: 202 | return fmt.Errorf("unknown action: %s", action) 203 | } 204 | 205 | if err2 != nil { 206 | return err2 207 | } 208 | if err1 != nil { 209 | return fmt.Errorf(err1.Err) 210 | } 211 | return nil 212 | } 213 | return fmt.Errorf("location server [%s] not found", info.LocServer) 214 | } 215 | 216 | func (gCli GrpcCli) Start(ctx context.Context, containerID string) error { 217 | return gCli.containerAction(ctx, "start", containerID) 218 | } 219 | 220 | func (gCli GrpcCli) Stop(ctx context.Context, containerID string) error { 221 | return gCli.containerAction(ctx, "stop", containerID) 222 | } 223 | 224 | func (gCli GrpcCli) Restart(ctx context.Context, containerID string) error { 225 | return gCli.containerAction(ctx, "restart", containerID) 226 | } 227 | 228 | func (gCli GrpcCli) Exec(ctx context.Context, container types.Container) (types.TTY, error) { 229 | logrus.Debugf("exec into container: %s (%s) (%v)", 230 | container.ID, container.Shell, container.Exec) 231 | if container.ID == "" { 232 | return nil, fmt.Errorf("container not found") 233 | } 234 | 235 | cli, exist := gCli.clients[container.LocServer] 236 | if !exist { 237 | return nil, fmt.Errorf("location server [%s] not found", container.LocServer) 238 | } 239 | if !cli.alive() { 240 | return nil, fmt.Errorf("remote server %s is not ready: %s", container.LocServer, cli.state()) 241 | } 242 | 243 | execClient, err := cli.client.Exec(ctx) 244 | if err != nil { 245 | return nil, err 246 | } 247 | 248 | // send container info to server, 249 | // server will use another cli to exec into the container 250 | if err := execClient.Send(&pb.ExecOptions{ 251 | C: util.ConvertTpContainer(container), 252 | Auth: gCli.auth, 253 | }); err != nil { 254 | return nil, err 255 | } 256 | 257 | // start to read and write using this exec wrapper 258 | return newExecWrapper(execClient), nil 259 | } 260 | 261 | func (gCli GrpcCli) Close() error { 262 | for addr, cli := range gCli.clients { 263 | if err := cli.close(); err != nil { 264 | logrus.Errorf("close %s error: %s", addr, err) 265 | } 266 | } 267 | return nil 268 | } 269 | 270 | func (gCli GrpcCli) Logs(ctx context.Context, opts types.LogOptions) (io.ReadCloser, error) { 271 | logrus.Debugf("get container logs, id: %s", opts.ID) 272 | info := gCli.containers.Find(opts.ID) 273 | if info.ID == "" { 274 | return nil, fmt.Errorf("container not found") 275 | } 276 | 277 | cli, exist := gCli.clients[info.LocServer] 278 | if !exist { 279 | return nil, fmt.Errorf("location server [%s] not found", info.LocServer) 280 | } 281 | if !cli.alive() { 282 | return nil, fmt.Errorf("remote server %s is not ready: %s", info.LocServer, cli.state()) 283 | } 284 | 285 | logsClient, err := cli.client.Logs(ctx, &pb.LogOpts{ 286 | Follow: opts.Follow, 287 | Tail: opts.Tail, 288 | C: &pb.ContainerID{ 289 | Id: info.ID, 290 | Auth: gCli.auth, 291 | }, 292 | }) 293 | if err != nil { 294 | return nil, err 295 | } 296 | 297 | logrus.Debugf("start to pipe...") 298 | pr, pw := io.Pipe() 299 | go func() { 300 | defer pw.Close() 301 | defer pr.Close() 302 | for { 303 | in, err := logsClient.Recv() 304 | if err != nil { 305 | if grpc.ErrorDesc(err) != context.Canceled.Error() { 306 | logrus.Errorf("logs recv error: %s", err) 307 | } 308 | break 309 | } 310 | if _, err = pw.Write(in.GetIn()); err != nil { 311 | logrus.Errorf("logs write to remote error: %s", err) 312 | break 313 | } 314 | } 315 | }() 316 | 317 | return pr, nil 318 | } 319 | -------------------------------------------------------------------------------- /container/grpc/grpc_tty.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "time" 5 | 6 | pb "github.com/wrfly/container-web-tty/proxy/pb" 7 | ) 8 | 9 | // execWrapper implement webtty.Slave 10 | type execWrapper struct { 11 | exec pb.ContainerServer_ExecClient 12 | activeChan chan struct{} 13 | } 14 | 15 | type resizeFunction func(width int, height int) error 16 | 17 | func newExecWrapper(client pb.ContainerServer_ExecClient) *execWrapper { 18 | return &execWrapper{ 19 | exec: client, 20 | activeChan: make(chan struct{}, 5), 21 | } 22 | } 23 | 24 | func (enj *execWrapper) Read(p []byte) (n int, err error) { 25 | go func() { 26 | if len(enj.activeChan) != 0 { 27 | return 28 | } 29 | enj.activeChan <- struct{}{} 30 | }() 31 | 32 | execOpts, err := enj.exec.Recv() 33 | if err != nil { 34 | return 0, err 35 | } 36 | // logrus.Debugf("output: %s\n", execOpts.Cmd.Out) 37 | copy(p, execOpts.Cmd.Out) 38 | return len(execOpts.Cmd.Out), nil 39 | 40 | } 41 | 42 | func (enj *execWrapper) Write(p []byte) (n int, err error) { 43 | // logrus.Debugf("input: %s\n", p) 44 | return len(p), enj.exec.Send(&pb.ExecOptions{ 45 | Cmd: &pb.Io{ 46 | In: p, 47 | }, 48 | }) 49 | } 50 | 51 | func (enj *execWrapper) Exit() error { 52 | enj.Write([]byte{3}) 53 | enj.Write([]byte{4}) 54 | close(enj.activeChan) 55 | return enj.exec.CloseSend() 56 | } 57 | 58 | func (enj *execWrapper) ActiveChan() <-chan struct{} { 59 | return enj.activeChan 60 | } 61 | 62 | func (enj *execWrapper) WindowTitleVariables() map[string]interface{} { 63 | return map[string]interface{}{} 64 | } 65 | 66 | func (enj *execWrapper) ResizeTerminal(width int, height int) (err error) { 67 | // since the process may not up so fast, give it 150ms 68 | // retry 3 times 69 | for i := 0; i < 3; i++ { 70 | if err = enj.resize(width, height); err == nil { 71 | return 72 | } 73 | time.Sleep(time.Millisecond * 50) 74 | } 75 | return 76 | } 77 | 78 | func (enj *execWrapper) resize(width int, height int) error { 79 | return enj.exec.Send(&pb.ExecOptions{ 80 | Ws: &pb.WindowSize{ 81 | Height: int32(height), 82 | Width: int32(width), 83 | }, 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /container/kube/kubectl.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/sirupsen/logrus" 13 | api "k8s.io/api/core/v1" 14 | v1 "k8s.io/api/core/v1" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/client-go/kubernetes" 17 | restclient "k8s.io/client-go/rest" 18 | "k8s.io/client-go/tools/clientcmd" 19 | "k8s.io/client-go/tools/remotecommand" 20 | 21 | "github.com/wrfly/container-web-tty/config" 22 | "github.com/wrfly/container-web-tty/types" 23 | ) 24 | 25 | type KubeCli struct { 26 | cli *kubernetes.Clientset 27 | config *restclient.Config 28 | containers *types.Containers 29 | } 30 | 31 | func NewCli(conf config.KubeConfig) (*KubeCli, error) { 32 | // use the current context in kubeconfig 33 | kubeConfig, err := clientcmd.BuildConfigFromFlags("", conf.ConfigPath) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | // create the clientset 39 | clientset, err := kubernetes.NewForConfig(kubeConfig) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | // get namespaces 45 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) 46 | defer cancel() 47 | namespaceList, err := clientset.CoreV1().Namespaces().List(ctx, 48 | metav1.ListOptions{}) 49 | if err != nil { 50 | return nil, err 51 | } 52 | ns := []string{} 53 | for _, namespace := range namespaceList.Items { 54 | ns = append(ns, namespace.Name) 55 | } 56 | logrus.Infof("New kube client: host [%s], namespaces [%s]", 57 | kubeConfig.Host, strings.Join(ns, ",")) 58 | 59 | k := &KubeCli{ 60 | cli: clientset, 61 | containers: &types.Containers{}, 62 | config: kubeConfig, 63 | } 64 | k.List(context.Background()) 65 | 66 | return k, nil 67 | } 68 | 69 | func (kube KubeCli) GetInfo(ctx context.Context, cid string) types.Container { 70 | if kube.containers.Len() == 0 { 71 | logrus.Debugf("zero containers, get cid %s", cid) 72 | kube.List(ctx) 73 | } 74 | 75 | // find in containers 76 | logrus.Debugf("find cid: %s", cid) 77 | container := kube.containers.Find(cid) 78 | if container.ID != "" { 79 | if container.Shell == "" { 80 | shell := kube.getShell(ctx, cid) 81 | kube.containers.SetShell(cid, shell) 82 | container.Shell = shell 83 | } 84 | return container 85 | } 86 | 87 | return types.Container{} 88 | } 89 | 90 | func trimContainerIDPrefix(id string) string { 91 | return strings.TrimPrefix(strings.TrimPrefix(id, "docker://"), "containerd://") 92 | } 93 | 94 | func containerReady(ready bool) string { 95 | if ready { 96 | return "Ready" 97 | } 98 | return "Not Ready" 99 | } 100 | 101 | func containerStartTime(state v1.ContainerState) time.Duration { 102 | if state.Running == nil { 103 | return 0 104 | } 105 | return time.Since(state.Running.StartedAt.Time).Round(time.Second) 106 | } 107 | 108 | func (kube KubeCli) List(ctx context.Context) []types.Container { 109 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) 110 | defer cancel() 111 | pods, err := kube.cli.CoreV1().Pods("").List(ctx, metav1.ListOptions{}) 112 | if err != nil { 113 | logrus.Errorf("kubectl list pods error: %s", err) 114 | return nil 115 | } 116 | 117 | containers := []types.Container{} 118 | 119 | for _, pod := range pods.Items { 120 | // map key is name 121 | containerMap := make(map[string]types.Container, 0) 122 | 123 | spec := pod.Spec 124 | status := pod.Status 125 | 126 | podIP := pod.Status.PodIP 127 | hostIP := pod.Status.HostIP 128 | podState := string(status.Phase) 129 | 130 | // spec 131 | for _, container := range spec.Containers { 132 | c := types.Container{ 133 | Command: strings.Join(container.Command, " "), 134 | Image: container.Image, 135 | } 136 | containerMap[container.Name] = c 137 | } 138 | 139 | // status 140 | for _, container := range status.ContainerStatuses { 141 | id := trimContainerIDPrefix(container.ContainerID) 142 | if id == "" { 143 | continue 144 | } 145 | c := types.Container{ 146 | ID: id, 147 | PodName: pod.GetName(), 148 | ContainerName: container.Name, 149 | Namespace: pod.GetNamespace(), 150 | Name: container.Name, 151 | State: fmt.Sprintf("%s / %s", containerReady(container.Ready), podState), 152 | Status: fmt.Sprintf("age: %s; restart %d", containerStartTime(container.State), container.RestartCount), 153 | IPs: func() []string { 154 | if podIP != hostIP { 155 | return []string{podIP, hostIP} 156 | } 157 | return []string{hostIP} 158 | }(), 159 | Image: containerMap[container.Name].Image, 160 | Command: containerMap[container.Name].Command, 161 | } 162 | logrus.Debugf("get container: %+v\n", c) 163 | containers = append(containers, c) 164 | } 165 | } 166 | 167 | kube.containers.Set(containers) 168 | 169 | return containers 170 | } 171 | 172 | func (kube KubeCli) exist(ctx context.Context, containerID, path string) bool { 173 | info := kube.containers.Find(containerID) 174 | if info.ID == "" { 175 | return false 176 | } 177 | 178 | restClient := kube.cli.CoreV1().RESTClient() 179 | 180 | req := restClient.Post(). 181 | Resource("pods"). 182 | Name(info.PodName). 183 | Namespace(info.Namespace). 184 | SubResource("exec"). 185 | Param("container", info.ContainerName). 186 | Param("command", "ls"). 187 | Param("command", path). 188 | Param("stdout", "true"). 189 | Param("stdin", "false"). 190 | Param("tty", "false") 191 | 192 | logrus.Debugf("POST to %s", req.URL()) 193 | exec, err := remotecommand.NewSPDYExecutor(kube.config, "POST", req.URL()) 194 | if err != nil { 195 | logrus.Errorf("exist exec: setup executor error: [%v]", err) 196 | return false 197 | } 198 | 199 | err = exec.Stream(remotecommand.StreamOptions{ 200 | Stdout: new(bytes.Buffer), 201 | Tty: false, 202 | }) 203 | if err != nil { 204 | logrus.Debugf("exist exec error: [%v]", err) 205 | return false 206 | } 207 | 208 | logrus.Debugf("container %s exist %s", containerID, path) 209 | return true 210 | 211 | } 212 | 213 | func (kube KubeCli) getShell(ctx context.Context, cid string) string { 214 | logrus.Debugf("get container's shell path, cid: %s", cid) 215 | for _, sh := range config.SHELL_LIST { 216 | if kube.exist(ctx, cid, sh) { 217 | logrus.Debugf("get shell path %s", sh) 218 | return sh 219 | } 220 | } 221 | // generally it won't come so far 222 | return "" 223 | } 224 | 225 | func (kube KubeCli) Start(ctx context.Context, cid string) error { 226 | return nil 227 | } 228 | 229 | func (kube KubeCli) Stop(ctx context.Context, cid string) error { 230 | return nil 231 | } 232 | 233 | func (kube KubeCli) Restart(ctx context.Context, cid string) error { 234 | return nil 235 | } 236 | 237 | func (kube KubeCli) Exec(ctx context.Context, c types.Container) (types.TTY, error) { 238 | logrus.Debugf("exec pod: %v", c) 239 | if c.PodName == "" || c.Namespace == "" { 240 | return nil, fmt.Errorf("PodName or Namespace is empty") 241 | } 242 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) 243 | defer cancel() 244 | pod, err := kube.cli.CoreV1().Pods(c.Namespace). 245 | Get(ctx, c.PodName, metav1.GetOptions{}) 246 | if err != nil { 247 | return nil, err 248 | } 249 | if pod.Status.Phase == api.PodSucceeded || pod.Status.Phase == api.PodFailed { 250 | return nil, 251 | fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", 252 | pod.Status.Phase) 253 | } 254 | 255 | cmds := []string{c.Shell, "-l"} 256 | if opts := c.Exec; opts.Cmd != "" { 257 | cmds = []string{c.Shell, "-l", opts.Cmd} 258 | } 259 | logrus.Debugf("exec with cmd: %v", cmds) 260 | 261 | restClient := kube.cli.CoreV1().RESTClient() 262 | req := restClient.Post(). 263 | Resource("pods"). 264 | Name(c.PodName). 265 | Namespace(c.Namespace). 266 | SubResource("exec"). 267 | Param("container", c.ContainerName). 268 | Param("stdin", "true"). 269 | Param("stdout", "true"). 270 | Param("tty", "true") 271 | // TODO: k8s exec user & env 272 | 273 | // set commands 274 | for _, cmd := range cmds { 275 | req.Param("command", cmd) 276 | } 277 | 278 | enj := newInjector(ctx) 279 | 280 | logrus.Debugf("POST to %s", req.URL()) 281 | exec, err := remotecommand.NewSPDYExecutor(kube.config, "POST", req.URL()) 282 | if err != nil { 283 | return nil, err 284 | } 285 | 286 | go func() { 287 | err = exec.Stream(remotecommand.StreamOptions{ 288 | Stdin: enj.ttyIn, 289 | Stdout: enj.ttyOut, 290 | Tty: true, 291 | TerminalSizeQueue: enj.sq, 292 | }) 293 | if err != nil { 294 | logrus.Errorf("exec error: [%v]", err) 295 | } 296 | logrus.Debug("exec done") 297 | // close in and out 298 | enj.ttyIn.Close() 299 | enj.ttyOut.Close() 300 | }() 301 | 302 | logrus.Debug("return enj") 303 | return &enj, nil 304 | } 305 | 306 | func (kube KubeCli) Close() error { 307 | // no need to close 308 | return nil 309 | } 310 | 311 | func (kube KubeCli) Logs(ctx context.Context, 312 | opts types.LogOptions) (io.ReadCloser, error) { 313 | c := kube.GetInfo(ctx, opts.ID) 314 | logrus.Debugf("get pod logs: %v", c) 315 | if c.PodName == "" || c.Namespace == "" { 316 | return nil, fmt.Errorf("PodName or Namespace is empty") 317 | } 318 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) 319 | defer cancel() 320 | pod, err := kube.cli.CoreV1().Pods(c.Namespace). 321 | Get(ctx, c.PodName, metav1.GetOptions{}) 322 | if err != nil { 323 | return nil, err 324 | } 325 | if pod.Status.Phase == api.PodSucceeded || pod.Status.Phase == api.PodFailed { 326 | return nil, 327 | fmt.Errorf("cannot get container logs in a completed pod; current phase is %s", 328 | pod.Status.Phase) 329 | } 330 | 331 | req := kube.cli.CoreV1().RESTClient().Get(). 332 | Namespace(c.Namespace). 333 | Name(c.PodName). 334 | Resource("pods"). 335 | SubResource("log"). 336 | Param("follow", strconv.FormatBool(opts.Follow)). 337 | Param("container", c.ContainerName). 338 | Param("tailLines", opts.Tail) 339 | 340 | // Param("previous", strconv.FormatBool(logOptions.Previous)). 341 | // Param("timestamps", strconv.FormatBool(logOptions.Timestamps)) 342 | 343 | // if logOptions.SinceSeconds != nil { 344 | // req.Param("sinceSeconds", strconv.FormatInt(*logOptions.SinceSeconds, 10)) 345 | // } 346 | // if logOptions.SinceTime != nil { 347 | // req.Param("sinceTime", logOptions.SinceTime.Format(time.RFC3339)) 348 | // } 349 | // if logOptions.LimitBytes != nil { 350 | // req.Param("limitBytes", strconv.FormatInt(*logOptions.LimitBytes, 10)) 351 | // } 352 | 353 | return req.Stream(ctx) 354 | } 355 | -------------------------------------------------------------------------------- /container/kube/kubectl_test.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/sirupsen/logrus" 10 | 11 | "github.com/wrfly/container-web-tty/config" 12 | "github.com/wrfly/container-web-tty/types" 13 | ) 14 | 15 | func TestKubeCli(t *testing.T) { 16 | _, err := os.Stat("/home/mr/.kube/config") 17 | if err != nil { 18 | return 19 | } 20 | 21 | logrus.SetLevel(logrus.DebugLevel) 22 | 23 | k, err := NewCli(config.KubeConfig{ConfigPath: "/home/mr/.kube/config"}) 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | 28 | ctx := context.Background() 29 | execContainer := types.Container{} 30 | 31 | cs := k.List(ctx) 32 | for _, c := range cs { 33 | cc := k.GetInfo(ctx, c.ID) 34 | if cc.Shell != "" { 35 | execContainer = cc 36 | break 37 | } 38 | } 39 | 40 | tty, err := k.Exec(ctx, execContainer) 41 | if err != nil { 42 | t.Error(err) 43 | return 44 | } 45 | defer tty.Exit() 46 | 47 | time.Sleep(time.Second) 48 | _, err = tty.Write([]byte("pwd\n")) 49 | if err != nil { 50 | t.Error(err) 51 | return 52 | } 53 | 54 | p := make([]byte, 10) 55 | n, err := tty.Read(p) 56 | if err != nil { 57 | t.Logf("out: %s", p[:n]) 58 | t.Error(err) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /container/kube/pod_struct.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | /* 4 | 5 | { 6 | TypeMeta:{Kind: APIVersion:} 7 | ObjectMeta:{ 8 | Name:kube-scheduler-vmwareserver02 9 | GenerateName: 10 | Namespace:kube-system 11 | SelfLink:/api/v1/namespaces/kube-system/pods/kube-scheduler-vmwareserver02 12 | UID:8a539ebc-5813-11e8-b5ed-b82a72d9a2cd 13 | ResourceVersion:322 14 | Generation:0 15 | CreationTimestamp:2018-05-15 15:42:38 +0800 CST 16 | DeletionTimestamp: 17 | DeletionGracePeriodSeconds: 18 | Labels:map[component:kube-scheduler tier:control-plane] 19 | Annotations:map[ 20 | kubernetes.io/config.hash:96944ce896a1ba4844bab386f40c0acc 21 | kubernetes.io/config.mirror:96944ce896a1ba4844bab386f40c0acc 22 | kubernetes.io/config.seen:2018-05-15T15:39:21.319090913+08:00 23 | kubernetes.io/config.source:file 24 | scheduler.alpha.kubernetes.io/critical-pod:] 25 | OwnerReferences:[] 26 | Initializers:nil 27 | Finalizers:[] 28 | ClusterName: 29 | } 30 | Spec:{ 31 | Volumes:[ 32 | { 33 | Name:kubeconfig 34 | VolumeSource:{ 35 | HostPath:&HostPathVolumeSource{ 36 | Path:/etc/kubernetes/scheduler.conf, 37 | Type:*FileOrCreate, 38 | } 39 | EmptyDir:nil 40 | GCEPersistentDisk:nil 41 | AWSElasticBlockStore:nil 42 | GitRepo:nil 43 | Secret:nil 44 | NFS:nil 45 | ISCSI:nil 46 | Glusterfs:nil 47 | PersistentVolumeClaim:nil 48 | RBD:nil 49 | FlexVolume:nil 50 | Cinder:nil 51 | CephFS:nil 52 | Flocker:nil 53 | DownwardAPI:nil 54 | FC:nil 55 | AzureFile:nil 56 | ConfigMap:nil 57 | VsphereVolume:nil 58 | Quobyte:nil 59 | AzureDisk:nil 60 | PhotonPersistentDisk:nil 61 | Projected:nil 62 | PortworxVolume:nil 63 | ScaleIO:nil 64 | StorageOS:nil 65 | } 66 | } 67 | } 68 | ] 69 | InitContainers:[] 70 | Containers:[ 71 | { 72 | Name:kube-scheduler 73 | Image:gcr.io/google_containers/kube-scheduler-amd64:v1.9.7 74 | Command:[ 75 | kube-scheduler --address=127.0.0.1 --leader-elect=true --kubeconfig=/etc/kubernetes/scheduler.conf 76 | ] 77 | Args:[] 78 | WorkingDir: 79 | Ports:[] 80 | EnvFrom:[] 81 | Env:[] 82 | Resources:{ 83 | Limits:map[] 84 | Requests:map[cpu:{i:{value:100 scale:-3} d:{Dec:} s:100m Format:DecimalSI}] 85 | } 86 | VolumeMounts:[{Name:kubeconfig ReadOnly:true MountPath:/etc/kubernetes/scheduler.conf SubPath: MountPropagation:}] 87 | VolumeDevices:[] 88 | LivenessProbe:&Probe{ 89 | Handler:Handler{ 90 | Exec:nil, 91 | HTTPGet:&HTTPGetAction{ 92 | Path:/healthz, 93 | Port:10251, 94 | Host:127.0.0.1, 95 | Scheme:HTTP, 96 | HTTPHeaders:[], 97 | }, 98 | TCPSocket:nil, 99 | }, 100 | InitialDelaySeconds:15, 101 | TimeoutSeconds:15, 102 | PeriodSeconds:10, 103 | SuccessThreshold:1, 104 | FailureThreshold:8, 105 | } 106 | ReadinessProbe:nil 107 | Lifecycle:nil 108 | TerminationMessagePath:/dev/termination-log 109 | TerminationMessagePolicy:File 110 | ImagePullPolicy:IfNotPresent 111 | SecurityContext:nil 112 | Stdin:false 113 | StdinOnce:false 114 | TTY:false 115 | } 116 | ] 117 | RestartPolicy:Always 118 | TerminationGracePeriodSeconds:0xc4202299b8 119 | ActiveDeadlineSeconds: 120 | DNSPolicy:ClusterFirst 121 | NodeSelector:map[] 122 | ServiceAccountName: 123 | DeprecatedServiceAccount: 124 | AutomountServiceAccountToken: 125 | NodeName:vmwareserver02 126 | HostNetwork:true 127 | HostPID:false 128 | HostIPC:false 129 | ShareProcessNamespace: 130 | SecurityContext:&PodSecurityContext{SELinuxOptions:nil,RunAsUser:nil,RunAsNonRoot:nil,SupplementalGroups:[],FSGroup:nil,RunAsGroup:nil,} 131 | ImagePullSecrets:[] 132 | Hostname: 133 | Subdomain: 134 | Affinity:nil 135 | SchedulerName:default-scheduler 136 | Tolerations:[ 137 | { 138 | Key: 139 | Operator:Exists 140 | Value: 141 | Effect:NoExecute 142 | TolerationSeconds: 143 | } 144 | ] 145 | HostAliases:[] 146 | PriorityClassName: 147 | Priority: 148 | DNSConfig:nil 149 | } 150 | Status:{ 151 | Phase:Running 152 | Conditions:[ 153 | { 154 | Type:Initialized 155 | Status:True 156 | LastProbeTime:0001-01-01 00:00:00 +0000 UTC 157 | LastTransitionTime:2018-05-15 15:39:26 +0800 CST 158 | Reason: 159 | Message: 160 | }{ 161 | Type:Ready 162 | Status:True 163 | LastProbeTime:0001-01-01 00:00:00 +0000 UTC 164 | LastTransitionTime:2018-05-15 15:39:56 +0800 CST 165 | Reason: 166 | Message: 167 | }{ 168 | Type:PodScheduled 169 | Status:True 170 | LastProbeTime:0001-01-01 00:00:00 +0000 UTC 171 | LastTransitionTime:2018-05-15 15:39:26 +0800 CST 172 | Reason: 173 | Message: 174 | } 175 | ] 176 | Message: 177 | Reason: 178 | NominatedNodeName: 179 | HostIP:10.6.8.102 180 | PodIP:10.6.8.102 181 | StartTime:2018-05-15 15:39:26 +0800 CST 182 | InitContainerStatuses:[] 183 | ContainerStatuses:[ 184 | { 185 | Name:kube-scheduler 186 | State:{ 187 | Waiting:nil 188 | Running:&ContainerStateRunning{StartedAt:2018-05-15 15:39:55 +0800 CST,} 189 | Terminated:nil 190 | } 191 | LastTerminationState:{ 192 | Waiting:nil 193 | Running:nil 194 | Terminated:nil 195 | } 196 | Ready:true 197 | RestartCount:0 198 | Image:gcr.io/google_containers/kube-scheduler-amd64:v1.9.7 199 | ImageID:docker-pullable://gcr.io/google_containers/kube-scheduler-amd64@sha256:9a8c3bd7eae5cd6e21be8cb7067843e9a0416e1d0b72c1e0fa53b30c2db518ba 200 | ContainerID:docker://f9d632739a2bb5b8b91804c564d77b940fb5510aa8c9a6d1410ee08a2d7e0498 201 | } 202 | ] 203 | QOSClass:Burstable 204 | } 205 | } 206 | 207 | */ 208 | -------------------------------------------------------------------------------- /container/kube/tty.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "time" 7 | 8 | "github.com/sirupsen/logrus" 9 | "k8s.io/client-go/tools/remotecommand" 10 | ) 11 | 12 | type execInjector struct { 13 | r io.ReadCloser 14 | w io.WriteCloser 15 | ttyIn io.ReadCloser 16 | ttyOut io.WriteCloser 17 | 18 | sq *sizeQueue 19 | activeChan chan struct{} 20 | } 21 | 22 | func newInjector(ctx context.Context) execInjector { 23 | 24 | r, out := io.Pipe() 25 | in, w := io.Pipe() 26 | sq := &sizeQueue{ 27 | ctx: ctx, 28 | resizeChan: make(chan remotecommand.TerminalSize), 29 | } 30 | enj := execInjector{ 31 | r: r, 32 | w: w, 33 | ttyIn: in, 34 | ttyOut: out, 35 | sq: sq, 36 | activeChan: make(chan struct{}, 5), 37 | } 38 | 39 | return enj 40 | } 41 | 42 | type resizeFunction func(width int, height int) error 43 | 44 | func (enj *execInjector) Read(p []byte) (n int, err error) { 45 | go func() { 46 | if len(enj.activeChan) == 0 { 47 | enj.activeChan <- struct{}{} 48 | } 49 | }() 50 | return enj.r.Read(p) 51 | } 52 | 53 | func (enj *execInjector) Write(p []byte) (n int, err error) { 54 | return enj.w.Write(p) 55 | } 56 | 57 | func (enj *execInjector) Exit() error { 58 | enj.Write([]byte{3}) // ^C 59 | enj.Write([]byte{4}) // ^D 60 | 61 | enj.r.Close() 62 | enj.w.Close() 63 | enj.ttyIn.Close() 64 | enj.ttyOut.Close() 65 | enj.sq.close() 66 | close(enj.activeChan) 67 | 68 | return nil 69 | } 70 | 71 | func (enj *execInjector) ActiveChan() <-chan struct{} { 72 | return enj.activeChan 73 | } 74 | 75 | func (enj *execInjector) WindowTitleVariables() map[string]interface{} { 76 | return map[string]interface{}{} 77 | } 78 | 79 | func (enj *execInjector) ResizeTerminal(width int, height int) (err error) { 80 | logrus.Debugf("resize terminal to: %dx%d", width, height) 81 | for i := 0; i < 3; i++ { 82 | // there is a delay somehow, use this trick method to avoid it 83 | enj.sq.resize(width, height) 84 | time.Sleep(time.Millisecond * 50) 85 | } 86 | return 87 | } 88 | 89 | type sizeQueue struct { 90 | ctx context.Context 91 | resizeChan chan remotecommand.TerminalSize 92 | } 93 | 94 | func (s *sizeQueue) Next() *remotecommand.TerminalSize { 95 | size, ok := <-s.resizeChan 96 | if !ok { 97 | return nil 98 | } 99 | return &size 100 | } 101 | 102 | func (s *sizeQueue) close() { 103 | close(s.resizeChan) 104 | } 105 | 106 | func (s *sizeQueue) resize(width int, height int) error { 107 | defer func() { 108 | recover() 109 | }() 110 | s.resizeChan <- remotecommand.TerminalSize{ 111 | Width: uint16(width), 112 | Height: uint16(height), 113 | } 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | web-tty: 5 | image: wrfly/container-web-tty:latest 6 | ports: 7 | - 8080:8080 8 | environment: 9 | - WEB_TTY_DEBUG=false 10 | - DOCKER_HOST=/var/run/docker.sock 11 | # - WEB_TTY_DOCKER_HOST=/var/run/docker.sock 12 | # both the env key is OK, first is WEB_TTY_DOCKER_HOST 13 | # if WEB_TTY_DOCKER_HOST not found, then try to use DOCKER_HOST 14 | volumes: 15 | - /var/run/docker.sock:/var/run/docker.sock -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wrfly/container-web-tty 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/docker/docker v24.0.5+incompatible 7 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a 8 | github.com/gin-gonic/gin v1.9.1 9 | github.com/golang/protobuf v1.5.3 10 | github.com/gorilla/websocket v1.5.0 11 | github.com/pkg/errors v0.9.1 12 | github.com/sirupsen/logrus v1.9.3 13 | github.com/stretchr/testify v1.8.4 14 | github.com/urfave/cli/v2 v2.25.7 15 | github.com/wrfly/ecp v0.2.1 16 | github.com/wrfly/pubsub v0.0.0-20200314104228-47828c5578b6 17 | golang.org/x/net v0.17.0 18 | google.golang.org/grpc v1.57.1 19 | k8s.io/api v0.28.1 20 | k8s.io/apimachinery v0.28.1 21 | k8s.io/client-go v0.28.1 22 | ) 23 | 24 | require ( 25 | github.com/Microsoft/go-winio v0.6.1 // indirect 26 | github.com/bytedance/sonic v1.10.0 // indirect 27 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 28 | github.com/chenzhuoyu/iasm v0.9.0 // indirect 29 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/docker/distribution v2.8.2+incompatible // indirect 32 | github.com/docker/go-connections v0.4.0 // indirect 33 | github.com/docker/go-units v0.5.0 // indirect 34 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 35 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 36 | github.com/gin-contrib/sse v0.1.0 // indirect 37 | github.com/go-logr/logr v1.2.4 // indirect 38 | github.com/go-openapi/jsonpointer v0.20.0 // indirect 39 | github.com/go-openapi/jsonreference v0.20.2 // indirect 40 | github.com/go-openapi/swag v0.22.4 // indirect 41 | github.com/go-playground/locales v0.14.1 // indirect 42 | github.com/go-playground/universal-translator v0.18.1 // indirect 43 | github.com/go-playground/validator/v10 v10.15.1 // indirect 44 | github.com/goccy/go-json v0.10.2 // indirect 45 | github.com/gogo/protobuf v1.3.2 // indirect 46 | github.com/google/gnostic-models v0.6.8 // indirect 47 | github.com/google/go-cmp v0.5.9 // indirect 48 | github.com/google/gofuzz v1.2.0 // indirect 49 | github.com/google/uuid v1.3.1 // indirect 50 | github.com/imdario/mergo v0.3.16 // indirect 51 | github.com/josharian/intern v1.0.0 // indirect 52 | github.com/json-iterator/go v1.1.12 // indirect 53 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 54 | github.com/leodido/go-urn v1.2.4 // indirect 55 | github.com/mailru/easyjson v0.7.7 // indirect 56 | github.com/mattn/go-isatty v0.0.19 // indirect 57 | github.com/moby/spdystream v0.2.0 // indirect 58 | github.com/moby/term v0.5.0 // indirect 59 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 60 | github.com/modern-go/reflect2 v1.0.2 // indirect 61 | github.com/morikuni/aec v1.0.0 // indirect 62 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 63 | github.com/opencontainers/go-digest v1.0.0 // indirect 64 | github.com/opencontainers/image-spec v1.1.0-rc4 // indirect 65 | github.com/pelletier/go-toml/v2 v2.0.9 // indirect 66 | github.com/pmezard/go-difflib v1.0.0 // indirect 67 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 68 | github.com/spf13/pflag v1.0.5 // indirect 69 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 70 | github.com/ugorji/go/codec v1.2.11 // indirect 71 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 72 | golang.org/x/arch v0.4.0 // indirect 73 | golang.org/x/crypto v0.17.0 // indirect 74 | golang.org/x/mod v0.12.0 // indirect 75 | golang.org/x/oauth2 v0.11.0 // indirect 76 | golang.org/x/sys v0.15.0 // indirect 77 | golang.org/x/term v0.15.0 // indirect 78 | golang.org/x/text v0.14.0 // indirect 79 | golang.org/x/time v0.3.0 // indirect 80 | golang.org/x/tools v0.12.0 // indirect 81 | google.golang.org/appengine v1.6.7 // indirect 82 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect 83 | google.golang.org/protobuf v1.31.0 // indirect 84 | gopkg.in/inf.v0 v0.9.1 // indirect 85 | gopkg.in/yaml.v2 v2.4.0 // indirect 86 | gopkg.in/yaml.v3 v3.0.1 // indirect 87 | gotest.tools/v3 v3.5.0 // indirect 88 | k8s.io/klog/v2 v2.100.1 // indirect 89 | k8s.io/kube-openapi v0.0.0-20230816210353-14e408962443 // indirect 90 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect 91 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 92 | sigs.k8s.io/structured-merge-diff/v4 v4.3.0 // indirect 93 | sigs.k8s.io/yaml v1.3.0 // indirect 94 | ) 95 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= 2 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 3 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 4 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 5 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 6 | github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= 7 | github.com/bytedance/sonic v1.10.0 h1:qtNZduETEIWJVIyDl01BeNxur2rW9OwTQ/yBqFRkKEk= 8 | github.com/bytedance/sonic v1.10.0/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= 9 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 10 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 11 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= 12 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= 13 | github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= 14 | github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 16 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 17 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= 22 | github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 23 | github.com/docker/docker v24.0.5+incompatible h1:WmgcE4fxyI6EEXxBRxsHnZXrO1pQ3smi0k/jho4HLeY= 24 | github.com/docker/docker v24.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 25 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 26 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 27 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 28 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 29 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= 30 | github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 31 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= 32 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= 33 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 34 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 35 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 36 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 37 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 38 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 39 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 40 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 41 | github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 42 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 43 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 44 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 45 | github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= 46 | github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= 47 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 48 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 49 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 50 | github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= 51 | github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 52 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 53 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 54 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 55 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 56 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 57 | github.com/go-playground/validator/v10 v10.15.1 h1:BSe8uhN+xQ4r5guV/ywQI4gO59C2raYcGffYWZEjZzM= 58 | github.com/go-playground/validator/v10 v10.15.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 59 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 60 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 61 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 62 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 63 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 64 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 65 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 66 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 67 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 68 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 69 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 70 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 71 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 72 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 73 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 74 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 75 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 76 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= 77 | github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= 78 | github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 79 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 80 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 81 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 82 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 83 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 84 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 85 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 86 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 87 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 88 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 89 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 90 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 91 | github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= 92 | github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 93 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 94 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 95 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 96 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 97 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 98 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 99 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 100 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 101 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 102 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 103 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 104 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 105 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 106 | github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= 107 | github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= 108 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 109 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 110 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 111 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 112 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 113 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 114 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 115 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 116 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 117 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 118 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 119 | github.com/onsi/ginkgo/v2 v2.9.4 h1:xR7vG4IXt5RWx6FfIjyAtsoMAtnc3C/rFXBBd2AjZwE= 120 | github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 121 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 122 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 123 | github.com/opencontainers/image-spec v1.1.0-rc4 h1:oOxKUJWnFC4YGHCCMNql1x4YaDfYBTS5Y4x/Cgeo1E0= 124 | github.com/opencontainers/image-spec v1.1.0-rc4/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= 125 | github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= 126 | github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 127 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 128 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 129 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 130 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 131 | github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= 132 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 133 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 134 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 135 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 136 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 137 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 138 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 139 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 140 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 141 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 142 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 143 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 144 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 145 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 146 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 147 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 148 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 149 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 150 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 151 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 152 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 153 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 154 | github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= 155 | github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= 156 | github.com/wrfly/ecp v0.2.1 h1:NmSUcqOTp9/EwfRNWOZdYHJDsceyPvo4o3wP0scCN3w= 157 | github.com/wrfly/ecp v0.2.1/go.mod h1:8rt/LxDJNLd6AxL56hbbbNWZfJLK4x8+UU4Fyey0m+8= 158 | github.com/wrfly/pubsub v0.0.0-20200314104228-47828c5578b6 h1:iuI+7TJcnnKB3WH08PmK5Y4c66Tf2XNXmnvLpIFFP30= 159 | github.com/wrfly/pubsub v0.0.0-20200314104228-47828c5578b6/go.mod h1:WFtPVb6GumrLEVcAHZPSbjZmvWgenDSi4ceFW7k1r30= 160 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 161 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 162 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 163 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 164 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 165 | golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= 166 | golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 167 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 168 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 169 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 170 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 171 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 172 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 173 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 174 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 175 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 176 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 177 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 178 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 179 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 180 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 181 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 182 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 183 | golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= 184 | golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= 185 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 186 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 187 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 188 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 189 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 190 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 191 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 192 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 193 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 194 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 195 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 196 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 197 | golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= 198 | golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= 199 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 200 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 201 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 202 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 203 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 204 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 205 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 206 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 207 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 208 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 209 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 210 | golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= 211 | golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= 212 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 213 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 214 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 215 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 216 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 217 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 218 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= 219 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= 220 | google.golang.org/grpc v1.57.1 h1:upNTNqv0ES+2ZOOqACwVtS3Il8M12/+Hz41RCPzAjQg= 221 | google.golang.org/grpc v1.57.1/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= 222 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 223 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 224 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 225 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 226 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 227 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 228 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 229 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 230 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 231 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 232 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 233 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 234 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 235 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 236 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 237 | gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= 238 | gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 239 | k8s.io/api v0.28.1 h1:i+0O8k2NPBCPYaMB+uCkseEbawEt/eFaiRqUx8aB108= 240 | k8s.io/api v0.28.1/go.mod h1:uBYwID+66wiL28Kn2tBjBYQdEU0Xk0z5qF8bIBqk/Dg= 241 | k8s.io/apimachinery v0.28.1 h1:EJD40og3GizBSV3mkIoXQBsws32okPOy+MkRyzh6nPY= 242 | k8s.io/apimachinery v0.28.1/go.mod h1:X0xh/chESs2hP9koe+SdIAcXWcQ+RM5hy0ZynB+yEvw= 243 | k8s.io/client-go v0.28.1 h1:pRhMzB8HyLfVwpngWKE8hDcXRqifh1ga2Z/PU9SXVK8= 244 | k8s.io/client-go v0.28.1/go.mod h1:pEZA3FqOsVkCc07pFVzK076R+P/eXqsgx5zuuRWukNE= 245 | k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= 246 | k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= 247 | k8s.io/kube-openapi v0.0.0-20230816210353-14e408962443 h1:CAIciCnJnSOQxPd0xvpV6JU3D4AJvnYbImPpFpO9Hnw= 248 | k8s.io/kube-openapi v0.0.0-20230816210353-14e408962443/go.mod h1:wZK2AVp1uHCp4VamDVgBP2COHZjqD1T68Rf0CM3YjSM= 249 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= 250 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 251 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 252 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 253 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 254 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 255 | sigs.k8s.io/structured-merge-diff/v4 v4.3.0 h1:UZbZAZfX0wV2zr7YZorDz6GXROfDFj6LvqCRm4VUVKk= 256 | sigs.k8s.io/structured-merge-diff/v4 v4.3.0/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 257 | sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= 258 | sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= 259 | -------------------------------------------------------------------------------- /images/bash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrfly/container-web-tty/2c216335d90866705a530d877ef685ca96676429/images/bash.png -------------------------------------------------------------------------------- /images/cmd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrfly/container-web-tty/2c216335d90866705a530d877ef685ca96676429/images/cmd.png -------------------------------------------------------------------------------- /images/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrfly/container-web-tty/2c216335d90866705a530d877ef685ca96676429/images/list.png -------------------------------------------------------------------------------- /images/logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrfly/container-web-tty/2c216335d90866705a530d877ef685ca96676429/images/logs.png -------------------------------------------------------------------------------- /images/sh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrfly/container-web-tty/2c216335d90866705a530d877ef685ca96676429/images/sh.png -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "acorn": "^8.10.0", 4 | "babel-core": "^6.26.3", 5 | "babel-loader": "^9.1.3", 6 | "babel-preset-es2015": "^6.24.1", 7 | "braces": "^3.0.2", 8 | "kind-of": "^6.0.3", 9 | "license-loader": "^0.7.0", 10 | "minimist": "^1.2.8", 11 | "randomatic": "^3.1.1", 12 | "ts-loader": "^6.2.2", 13 | "typescript": "^3.9.10", 14 | "uglifyjs-webpack-plugin": "^1.1.2", 15 | "webpack": "^5.88.2", 16 | "webpack-cli": "^3.3.12", 17 | "xterm": "^4.19.0" 18 | }, 19 | "dependencies": { 20 | "libapps": "github:yudai/libapps#release-hterm-1.70", 21 | "lodash": "^4.17.21", 22 | "serialize-javascript": "^6.0.1", 23 | "ssri": "^10.0.5", 24 | "xterm-addon-fit": "^0.3.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /js/src/hterm.ts: -------------------------------------------------------------------------------- 1 | import * as bare from "libapps"; 2 | 3 | export class Hterm { 4 | elem: HTMLElement; 5 | 6 | term: bare.hterm.Terminal; 7 | io: bare.hterm.IO; 8 | 9 | columns: number; 10 | rows: number; 11 | 12 | // to "show" the current message when removeMessage() is called 13 | message: string; 14 | 15 | constructor(elem: HTMLElement) { 16 | this.elem = elem; 17 | bare.hterm.defaultStorage = new bare.lib.Storage.Memory(); 18 | this.term = new bare.hterm.Terminal(); 19 | this.term.getPrefs().set("send-encoding", "raw"); 20 | this.term.decorate(this.elem); 21 | 22 | this.io = this.term.io.push(); 23 | this.term.installKeyboard(); 24 | }; 25 | 26 | info(): { columns: number, rows: number } { 27 | return { columns: this.columns, rows: this.rows }; 28 | }; 29 | 30 | output(data: string) { 31 | if (this.term.io != null) { 32 | this.term.io.writeUTF8(data); 33 | } 34 | }; 35 | 36 | showMessage(message: string, timeout: number) { 37 | this.message = message; 38 | if (timeout > 0) { 39 | this.term.io.showOverlay(message, timeout); 40 | } else { 41 | this.term.io.showOverlay(message, null); 42 | } 43 | }; 44 | 45 | removeMessage(): void { 46 | // there is no hideOverlay(), so show the same message with 0 sec 47 | this.term.io.showOverlay(this.message, 0); 48 | } 49 | 50 | setWindowTitle(title: string) { 51 | this.term.setWindowTitle(title); 52 | }; 53 | 54 | setPreferences(value: object) { 55 | Object.keys(value).forEach((key) => { 56 | this.term.getPrefs().set(key, value[key]); 57 | }); 58 | }; 59 | 60 | onInput(callback: (input: string) => void) { 61 | this.io.onVTKeystroke = (data) => { 62 | callback(data); 63 | }; 64 | this.io.sendString = (data) => { 65 | callback(data); 66 | }; 67 | }; 68 | 69 | onResize(callback: (colmuns: number, rows: number) => void) { 70 | this.io.onTerminalResize = (columns: number, rows: number) => { 71 | this.columns = columns; 72 | this.rows = rows; 73 | callback(columns, rows); 74 | }; 75 | }; 76 | 77 | deactivate(): void { 78 | this.io.onVTKeystroke = function(){}; 79 | this.io.sendString = function(){}; 80 | this.io.onTerminalResize = function(){}; 81 | this.term.uninstallKeyboard(); 82 | } 83 | 84 | reset(): void { 85 | this.removeMessage(); 86 | this.term.installKeyboard(); 87 | } 88 | 89 | close(): void { 90 | this.term.uninstallKeyboard(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /js/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Hterm } from "./hterm"; 2 | import { Xterm } from "./xterm"; 3 | import { Terminal, WebTTY, protocols } from "./webtty"; 4 | import { ConnectionFactory } from "./websocket"; 5 | 6 | // @TODO remove these 7 | declare var gotty_auth_token: string; 8 | declare var gotty_term: string; 9 | 10 | const elem = document.getElementById("terminal") 11 | 12 | if (elem !== null) { 13 | var term: Terminal; 14 | if (gotty_term == "hterm") { 15 | term = new Hterm(elem); 16 | } else { 17 | term = new Xterm(elem); 18 | } 19 | const httpsEnabled = window.location.protocol == "https:"; 20 | const url = (httpsEnabled ? 'wss://' : 'ws://') + window.location.host + window.location.pathname + 'ws'; 21 | const args = window.location.search; 22 | const factory = new ConnectionFactory(url, protocols); 23 | const wt = new WebTTY(term, factory, args, gotty_auth_token); 24 | const closer = wt.open(); 25 | 26 | window.addEventListener("unload", () => { 27 | closer(); 28 | term.close(); 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /js/src/websocket.ts: -------------------------------------------------------------------------------- 1 | export class ConnectionFactory { 2 | url: string; 3 | protocols: string[]; 4 | 5 | constructor(url: string, protocols: string[]) { 6 | this.url = url; 7 | this.protocols = protocols; 8 | }; 9 | 10 | create(): Connection { 11 | return new Connection(this.url, this.protocols); 12 | }; 13 | } 14 | 15 | export class Connection { 16 | bare: WebSocket; 17 | 18 | 19 | constructor(url: string, protocols: string[]) { 20 | this.bare = new WebSocket(url, protocols); 21 | } 22 | 23 | open() { 24 | // nothing todo for websocket 25 | }; 26 | 27 | close() { 28 | this.bare.close(); 29 | }; 30 | 31 | send(data: string) { 32 | this.bare.send(data); 33 | }; 34 | 35 | isOpen(): boolean { 36 | if (this.bare.readyState == WebSocket.CONNECTING || 37 | this.bare.readyState == WebSocket.OPEN) { 38 | return true 39 | } 40 | return false 41 | } 42 | 43 | onOpen(callback: () => void) { 44 | this.bare.onopen = (event) => { 45 | callback(); 46 | } 47 | }; 48 | 49 | onReceive(callback: (data: string) => void) { 50 | this.bare.onmessage = (event) => { 51 | callback(event.data); 52 | } 53 | }; 54 | 55 | onClose(callback: () => void) { 56 | this.bare.onclose = (event) => { 57 | callback(); 58 | }; 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /js/src/webtty.ts: -------------------------------------------------------------------------------- 1 | export const protocols = ["webtty"]; 2 | 3 | export const msgInputUnknown = '0'; 4 | export const msgInput = '1'; 5 | export const msgPing = '2'; 6 | export const msgResizeTerminal = '3'; 7 | 8 | export const msgUnknownOutput = '0'; 9 | export const msgOutput = '1'; 10 | export const msgPong = '2'; 11 | export const msgSetWindowTitle = '3'; 12 | export const msgSetPreferences = '4'; 13 | export const msgSetReconnect = '5'; 14 | 15 | 16 | export interface Terminal { 17 | info(): { columns: number, rows: number }; 18 | output(data: string): void; 19 | showMessage(message: string, timeout: number): void; 20 | removeMessage(): void; 21 | setWindowTitle(title: string): void; 22 | setPreferences(value: object): void; 23 | onInput(callback: (input: string) => void): void; 24 | onResize(callback: (colmuns: number, rows: number) => void): void; 25 | reset(): void; 26 | deactivate(): void; 27 | close(): void; 28 | } 29 | 30 | export interface Connection { 31 | open(): void; 32 | close(): void; 33 | send(data: string): void; 34 | isOpen(): boolean; 35 | onOpen(callback: () => void): void; 36 | onReceive(callback: (data: string) => void): void; 37 | onClose(callback: () => void): void; 38 | } 39 | 40 | export interface ConnectionFactory { 41 | create(): Connection; 42 | } 43 | 44 | 45 | export class WebTTY { 46 | term: Terminal; 47 | connectionFactory: ConnectionFactory; 48 | args: string; 49 | authToken: string; 50 | reconnect: number; 51 | 52 | constructor(term: Terminal, connectionFactory: ConnectionFactory, args: string, authToken: string) { 53 | this.term = term; 54 | this.connectionFactory = connectionFactory; 55 | this.args = args; 56 | this.authToken = authToken; 57 | this.reconnect = -1; 58 | }; 59 | 60 | open() { 61 | let connection = this.connectionFactory.create(); 62 | let pingTimer: number; 63 | let reconnectTimeout: number; 64 | 65 | 66 | const setup = () => { 67 | connection.onOpen(() => { 68 | const termInfo = this.term.info(); 69 | 70 | connection.send(JSON.stringify( 71 | { 72 | Arguments: this.args, 73 | AuthToken: this.authToken, 74 | } 75 | )); 76 | 77 | 78 | const resizeHandler = (colmuns: number, rows: number) => { 79 | connection.send( 80 | msgResizeTerminal + JSON.stringify( 81 | { 82 | columns: colmuns, 83 | rows: rows 84 | } 85 | ) 86 | ); 87 | }; 88 | 89 | this.term.onResize(resizeHandler); 90 | resizeHandler(termInfo.columns, termInfo.rows); 91 | 92 | this.term.onInput( 93 | (input: string) => { 94 | connection.send(msgInput + input); 95 | } 96 | ); 97 | 98 | pingTimer = setInterval(() => { 99 | connection.send(msgPing) 100 | }, 30 * 1000); 101 | 102 | }); 103 | 104 | connection.onReceive((data) => { 105 | const payload = data.slice(1); 106 | switch (data[0]) { 107 | case msgOutput: 108 | this.term.output(atob(payload)); 109 | break; 110 | case msgPong: 111 | break; 112 | case msgSetWindowTitle: 113 | this.term.setWindowTitle(payload); 114 | break; 115 | case msgSetPreferences: 116 | const preferences = JSON.parse(payload); 117 | this.term.setPreferences(preferences); 118 | break; 119 | case msgSetReconnect: 120 | const autoReconnect = JSON.parse(payload); 121 | console.log("Enabling reconnect: " + autoReconnect + " seconds") 122 | this.reconnect = autoReconnect; 123 | break; 124 | } 125 | }); 126 | 127 | connection.onClose(() => { 128 | clearInterval(pingTimer); 129 | this.term.deactivate(); 130 | this.term.showMessage("Connection Closed", 0); 131 | if (this.reconnect > 0) { 132 | reconnectTimeout = setTimeout(() => { 133 | connection = this.connectionFactory.create(); 134 | this.term.reset(); 135 | setup(); 136 | }, this.reconnect * 1000); 137 | } 138 | }); 139 | 140 | connection.open(); 141 | } 142 | 143 | setup(); 144 | return () => { 145 | clearTimeout(reconnectTimeout); 146 | connection.close(); 147 | } 148 | }; 149 | }; 150 | -------------------------------------------------------------------------------- /js/src/xterm.ts: -------------------------------------------------------------------------------- 1 | import { lib } from "libapps" 2 | import { FitAddon } from 'xterm-addon-fit'; 3 | import { Terminal } from 'xterm'; 4 | 5 | export class Xterm { 6 | elem: HTMLElement; 7 | term: Terminal; 8 | fitAddon: FitAddon; 9 | resizeListener: () => void; 10 | decoder: lib.UTF8Decoder; 11 | 12 | message: HTMLElement; 13 | messageTimeout: number; 14 | messageTimer: number; 15 | 16 | 17 | constructor(elem: HTMLElement) { 18 | this.elem = elem; 19 | this.term = new Terminal(); 20 | this.messageTimeout = 2000; 21 | this.fitAddon = new FitAddon(); 22 | this.term.loadAddon(this.fitAddon); 23 | 24 | 25 | if (elem.ownerDocument != null){ 26 | this.message = elem.ownerDocument.createElement("div"); 27 | this.message.className = "xterm-overlay"; 28 | } 29 | 30 | this.resizeListener = () => { 31 | this.fitAddon.fit(); 32 | this.term.scrollToBottom(); 33 | this.showMessage(String(this.term.cols) + "x" + String(this.term.rows), this.messageTimeout); 34 | }; 35 | window.addEventListener("resize", this.resizeListener); 36 | 37 | this.term.open(elem); 38 | this.resizeListener(); // resize first 39 | 40 | this.decoder = new lib.UTF8Decoder() 41 | }; 42 | 43 | info(): { columns: number, rows: number } { 44 | return { columns: this.term.cols, rows: this.term.rows }; 45 | }; 46 | 47 | output(data: string) { 48 | this.term.write(this.decoder.decode(data)); 49 | }; 50 | 51 | showMessage(message: string, timeout: number) { 52 | this.message.textContent = message; 53 | this.elem.appendChild(this.message); 54 | 55 | if (this.messageTimer) { 56 | clearTimeout(this.messageTimer); 57 | } 58 | if (timeout > 0) { 59 | this.messageTimer = setTimeout(() => { 60 | this.elem.removeChild(this.message); 61 | }, timeout); 62 | } 63 | }; 64 | 65 | removeMessage(): void { 66 | if (this.message.parentNode == this.elem) { 67 | this.elem.removeChild(this.message); 68 | } 69 | } 70 | 71 | setWindowTitle(title: string) { 72 | document.title = title; 73 | }; 74 | 75 | setPreferences(value: object) { 76 | }; 77 | 78 | onInput(callback: (input: string) => void) { 79 | this.term.onData(data => { 80 | callback(data); 81 | }); 82 | }; 83 | 84 | onResize(callback: (colmuns: number, rows: number) => void) { 85 | this.term.onResize(data => { 86 | callback(data.cols, data.rows); 87 | }); 88 | }; 89 | 90 | deactivate(): void { 91 | this.term.blur(); 92 | } 93 | 94 | reset(): void { 95 | this.removeMessage(); 96 | this.term.clear(); 97 | } 98 | 99 | close(): void { 100 | window.removeEventListener("resize", this.resizeListener); 101 | this.term.dispose(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true, 4 | "noUnusedLocals" : true, 5 | "noImplicitThis": true, 6 | "alwaysStrict": true, 7 | "outDir": "./dist/", 8 | "declaration": false, 9 | "sourceMap": false, 10 | "target": "es6", 11 | "module": "commonjs", 12 | "baseUrl": "./", 13 | "paths": { 14 | "*": ["./typings/*"] 15 | } 16 | }, 17 | "exclude": ["node_modules/*"] 18 | } 19 | -------------------------------------------------------------------------------- /js/typings/libapps/index.d.ts: -------------------------------------------------------------------------------- 1 | export declare namespace hterm { 2 | export class Terminal { 3 | io: IO; 4 | onTerminalReady: () => void; 5 | 6 | constructor(); 7 | getPrefs(): Prefs; 8 | decorate(HTMLElement); 9 | installKeyboard(): void; 10 | uninstallKeyboard(): void; 11 | setWindowTitle(title: string): void; 12 | reset(): void; 13 | softReset(): void; 14 | } 15 | 16 | export class IO { 17 | writeUTF8: ((data: string) => void); 18 | writeUTF16: ((data: string) => void); 19 | onVTKeystroke: ((data: string) => void) | null; 20 | sendString: ((data: string) => void) | null; 21 | onTerminalResize: ((columns: number, rows: number) => void) | null; 22 | 23 | push(): IO; 24 | writeUTF(data: string); 25 | showOverlay(message: string, timeout: number | null); 26 | } 27 | 28 | export class Prefs { 29 | set(key: string, value: string): void; 30 | } 31 | 32 | export var defaultStorage: lib.Storage; 33 | } 34 | 35 | export declare namespace lib { 36 | export interface Storage { 37 | } 38 | 39 | export interface Memory { 40 | new (): Storage; 41 | Memory(): Storage 42 | } 43 | 44 | export var Storage: { 45 | Memory: Memory 46 | } 47 | 48 | export class UTF8Decoder { 49 | decode(str: string) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); 2 | 3 | module.exports = { 4 | entry: "./src/main.ts", 5 | mode: "production", 6 | output: { 7 | filename: "gotty-bundle.js" 8 | }, 9 | devtool: "source-map", 10 | resolve: { 11 | extensions: [".ts", ".tsx", ".js"], 12 | }, 13 | module: { 14 | rules: [{ 15 | test: /\.tsx?$/, 16 | loader: "ts-loader", 17 | exclude: /node_modules/ 18 | }, 19 | { 20 | test: /\.js$/, 21 | include: /node_modules/, 22 | loader: 'license-loader' 23 | }, 24 | { 25 | test: /\.js$/, 26 | include: /src/, 27 | use: { 28 | loader: 'babel-loader', 29 | query: { 30 | presets: ["es2015"] 31 | } 32 | } 33 | } 34 | ] 35 | }, 36 | plugins: [ 37 | new UglifyJSPlugin() 38 | ] 39 | }; -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/sirupsen/logrus" 12 | "github.com/urfave/cli/v2" 13 | 14 | "github.com/wrfly/container-web-tty/config" 15 | "github.com/wrfly/container-web-tty/util" 16 | ) 17 | 18 | func main() { 19 | conf := config.New() 20 | appFlags := []cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "addr", 23 | EnvVars: util.EnvVars("address"), 24 | Usage: "server binding address", 25 | Value: "0.0.0.0", 26 | Destination: &conf.Server.Address, 27 | }, 28 | &cli.IntFlag{ 29 | Name: "port", 30 | Aliases: []string{"p"}, 31 | EnvVars: util.EnvVars("port"), 32 | Usage: "HTTP server port, -1 for disable the HTTP server", 33 | Value: 8080, 34 | Destination: &conf.Server.Port, 35 | }, 36 | &cli.StringFlag{ 37 | Name: "base", 38 | EnvVars: util.EnvVars("base"), 39 | Usage: "server base path, default is /", 40 | Value: "/", 41 | Destination: &conf.Server.Base, 42 | }, 43 | &cli.BoolFlag{ 44 | Name: "debug", 45 | Aliases: []string{"d"}, 46 | Value: false, 47 | EnvVars: util.EnvVars("debug"), 48 | Usage: "debug mode (log-level=debug enable pprof)", 49 | Destination: &conf.Debug, 50 | }, 51 | &cli.StringFlag{ 52 | Name: "backend", 53 | Aliases: []string{"b"}, 54 | EnvVars: util.EnvVars("backend"), 55 | Value: "docker", 56 | Usage: "backend type, 'docker' or 'kube' or 'grpc'(remote)", 57 | Destination: &conf.Backend.Type, 58 | }, 59 | &cli.StringFlag{ 60 | Name: "docker-host", 61 | EnvVars: append(util.EnvVars("docker-host"), "DOCKER_HOST"), 62 | Value: "/var/run/docker.sock", 63 | Usage: "docker host path", 64 | Destination: &conf.Backend.Docker.DockerHost, 65 | }, 66 | &cli.StringFlag{ 67 | Name: "docker-ps", 68 | EnvVars: util.EnvVars("docker-ps"), 69 | Usage: "docker ps options", 70 | Destination: &conf.Backend.Docker.PsOptions, 71 | }, 72 | &cli.StringFlag{ 73 | Name: "kube-config", 74 | EnvVars: util.EnvVars("kube-config"), 75 | Value: util.KubeConfigPath(), 76 | Usage: "kube config path", 77 | Destination: &conf.Backend.Kube.ConfigPath, 78 | }, 79 | &cli.IntFlag{ 80 | Name: "grpc-port", 81 | EnvVars: util.EnvVars("grpc-port"), 82 | Usage: "grpc server port, -1 for disable the grpc server", 83 | Value: -1, 84 | Destination: &conf.Server.GrpcPort, 85 | }, 86 | &cli.StringFlag{ 87 | Name: "grpc-servers", 88 | EnvVars: util.EnvVars("grpc-servers"), 89 | Usage: "upstream servers, for proxy mode(grpc address and port), use comma for split", 90 | }, 91 | &cli.StringFlag{ 92 | Name: "grpc-auth", 93 | EnvVars: util.EnvVars("grpc-auth"), 94 | Usage: "grpc auth token", 95 | Value: "password", 96 | Destination: &conf.Backend.GRPC.Auth, 97 | }, 98 | &cli.StringFlag{ 99 | Name: "grpc-proxy", 100 | EnvVars: util.EnvVars("grpc-proxy"), 101 | Usage: "grpc proxy address, in the format of http://127.0.0.1:8080 or socks5://127.0.0.1:1080", 102 | Value: "", 103 | Destination: &conf.Backend.GRPC.Proxy, 104 | }, 105 | &cli.StringFlag{ 106 | Name: "idle-time", 107 | EnvVars: util.EnvVars("idle-time"), 108 | Usage: "time out of an idle connection", 109 | }, 110 | &cli.BoolFlag{ 111 | Name: "control-all", 112 | Aliases: []string{"ctl-a"}, 113 | EnvVars: util.EnvVars("ctl-a"), 114 | Usage: "enable container control", 115 | Destination: &conf.Server.Control.All, 116 | }, 117 | &cli.BoolFlag{ 118 | Name: "control-start", 119 | Aliases: []string{"ctl-s"}, 120 | EnvVars: util.EnvVars("ctl-s"), 121 | Usage: "enable container start ", 122 | Destination: &conf.Server.Control.Start, 123 | }, 124 | &cli.BoolFlag{ 125 | Name: "control-stop", 126 | Aliases: []string{"ctl-t"}, 127 | EnvVars: util.EnvVars("ctl-t"), 128 | Usage: "enable container stop ", 129 | Destination: &conf.Server.Control.Stop, 130 | }, 131 | &cli.BoolFlag{ 132 | Name: "control-restart", 133 | Aliases: []string{"ctl-r"}, 134 | EnvVars: util.EnvVars("ctl-r"), 135 | Usage: "enable container restart", 136 | Destination: &conf.Server.Control.Restart, 137 | }, 138 | &cli.BoolFlag{ 139 | Name: "enable-collaborate", 140 | Aliases: []string{"clb"}, 141 | EnvVars: util.EnvVars("collaborate"), 142 | Usage: "shared terminal can write to the same TTY", 143 | Destination: &conf.Server.Collaborate, 144 | }, 145 | &cli.BoolFlag{ 146 | Name: "enable-audit", 147 | Aliases: []string{"audit"}, 148 | EnvVars: util.EnvVars("audit"), 149 | Usage: "enable audit the container outputs", 150 | Destination: &conf.Server.EnableAudit, 151 | }, 152 | &cli.StringFlag{ 153 | Name: "audit-dir", 154 | EnvVars: util.EnvVars("audit-dir"), 155 | Value: "audit", 156 | Usage: "container audit log dir path", 157 | Destination: &conf.Server.AuditLogDir, 158 | }, 159 | &cli.BoolFlag{ 160 | Name: "help", 161 | Aliases: []string{"h"}, 162 | Usage: "show help", 163 | }, 164 | } 165 | 166 | sort.Sort(cli.FlagsByName(appFlags)) 167 | 168 | app := &cli.App{ 169 | Name: "container-web-tty", 170 | Usage: "connect your containers via a web-tty", 171 | UsageText: "container-web-tty [global options]", 172 | Flags: appFlags, 173 | HideHelp: true, 174 | Authors: author, 175 | Version: fmt.Sprintf("version: %s\tcommit: %s\tdate: %s", 176 | Version, CommitID, BuildAt), 177 | Action: func(c *cli.Context) error { 178 | if c.Bool("help") { 179 | return cli.ShowAppHelp(c) 180 | } 181 | // parse idleTime 182 | t := c.String("idle-time") 183 | idleTime, err := time.ParseDuration(t) 184 | if err != nil && t != "" { 185 | logrus.Fatalf("parse idle-time error: %s", err) 186 | } else { 187 | conf.Server.IdleTime = idleTime 188 | } 189 | 190 | // defaultArgs := "-e HISTCONTROL=ignoredups -e TERM=xterm" 191 | 192 | ctl := conf.Server.Control 193 | if ctl.Start || ctl.Stop || ctl.Restart || ctl.All { 194 | conf.Server.Control.Enable = true 195 | } 196 | 197 | servers := strings.Split(c.String("grpc-servers"), ",") 198 | if servers[0] != "" { 199 | conf.Backend.GRPC.Servers = servers 200 | } 201 | if conf.Debug { 202 | logrus.SetLevel(logrus.DebugLevel) 203 | } else { 204 | gin.SetMode(gin.ReleaseMode) 205 | } 206 | logrus.Debugf("got config: %+v", conf) 207 | 208 | run(c, *conf) 209 | return nil 210 | }, 211 | } 212 | 213 | app.Run(os.Args) 214 | } 215 | -------------------------------------------------------------------------------- /proxy/pb/api.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package pbrpc; 3 | 4 | service containerServer { 5 | rpc GetInfo (ContainerID) returns (Container) {} 6 | rpc List (empty) returns (Containers) {} 7 | rpc Start (ContainerID) returns (err) {} 8 | rpc Stop (ContainerID) returns (err) {} 9 | rpc Restart (ContainerID) returns (err) {} 10 | rpc Exec(stream execOptions) returns (stream execOptions) {} 11 | rpc Ping(empty) returns (pong) {} 12 | rpc Logs(logOpts) returns (stream io) {} 13 | } 14 | 15 | message empty{ 16 | string auth = 1; 17 | } 18 | 19 | message pong { 20 | string msg = 1; 21 | } 22 | 23 | message err{ 24 | string err = 1; 25 | } 26 | 27 | message ContainerID { 28 | string id = 1; 29 | string auth = 2; 30 | } 31 | 32 | message logOpts { 33 | ContainerID c = 1; 34 | bool follow = 2; 35 | string tail = 3; 36 | } 37 | 38 | // Container instance 39 | message Container { 40 | string id = 1; 41 | string name = 2; 42 | string image = 3; 43 | string command = 4; 44 | string state = 5; 45 | string status = 6; 46 | repeated string ips = 7; 47 | string shell = 8; 48 | string pod_name = 9; 49 | string container_name = 10; 50 | string namespace = 11; 51 | string running_node = 12; 52 | string loc_server = 13; 53 | string execCmd = 14; 54 | string execUser = 15; 55 | string execEnv = 16; 56 | } 57 | 58 | message Containers { 59 | repeated Container cs = 1; 60 | } 61 | 62 | message io { 63 | bytes in = 1; 64 | bytes out = 2; 65 | } 66 | 67 | message windowSize { 68 | int32 height = 1; 69 | int32 width = 2; 70 | } 71 | 72 | message execOptions { 73 | io cmd = 1; 74 | Container c = 2; 75 | string err = 3; 76 | string auth = 4; 77 | windowSize ws = 5; 78 | } -------------------------------------------------------------------------------- /proxy/pb/generated.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package github.com.wrfly.containerwebtty.types; 3 | 4 | import "github.com/gogo/protobuf/gogoproto/gogo.proto"; 5 | 6 | option (gogoproto.protosizer_all) = true; 7 | option (gogoproto.sizer_all) = false; 8 | option go_package = "types"; 9 | 10 | // Container instance 11 | message Container { 12 | option (gogoproto.goproto_getters) = false; 13 | option (gogoproto.typedecl) = false; 14 | string id = 1 [(gogoproto.customname) = "ID"]; 15 | string name = 2; 16 | string image = 3; 17 | string command = 4; 18 | string state = 5; 19 | string status = 6; 20 | repeated string ips = 7 [(gogoproto.customname) = "IPs"]; 21 | string shell = 8; 22 | string pod_name = 9; 23 | string container_name = 10; 24 | string namespace = 11; 25 | string running_node = 12; 26 | string loc_server = 13; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/sirupsen/logrus" 9 | "google.golang.org/grpc" 10 | 11 | "github.com/wrfly/container-web-tty/container" 12 | pbrpc "github.com/wrfly/container-web-tty/proxy/pb" 13 | ) 14 | 15 | // GrpcServer is the grpc server 16 | type GrpcServer interface { 17 | Run(ctx context.Context, gCtx context.Context) error 18 | } 19 | 20 | type grpcServer struct { 21 | auth string 22 | port int 23 | cli container.Cli 24 | } 25 | 26 | // New proxy grpc server 27 | func New(auth string, port int, cli container.Cli) GrpcServer { 28 | logrus.Infof("New grpc server with port %d", port) 29 | return &grpcServer{ 30 | auth: auth, 31 | port: port, 32 | cli: cli, 33 | } 34 | } 35 | 36 | // Run the server 37 | func (gsrv *grpcServer) Run(ctx context.Context, gCtx context.Context) error { 38 | listener, err := net.Listen("tcp", fmt.Sprintf(":%d", gsrv.port)) 39 | if err != nil { 40 | return err 41 | } 42 | srv := grpc.NewServer() 43 | 44 | cs := newContainerService(gsrv.cli, gsrv.auth) 45 | pbrpc.RegisterContainerServerServer(srv, cs) 46 | 47 | // serve 48 | go func() { 49 | logrus.Infof("Running grpc server at :%d", gsrv.port) 50 | if err := srv.Serve(listener); err != nil { 51 | logrus.Errorf("GRPC API server error: %s", err) 52 | } else { 53 | logrus.Infof("GRPC API server stopped: %s", ctx.Err()) 54 | } 55 | }() 56 | 57 | // shutdown with cancel 58 | <-gCtx.Done() 59 | 60 | gStopped := make(chan struct{}) 61 | go func() { 62 | srv.GracefulStop() 63 | close(gStopped) 64 | }() 65 | 66 | select { 67 | case <-ctx.Done(): 68 | srv.Stop() 69 | case <-gStopped: 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /proxy/server.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/sirupsen/logrus" 9 | 10 | "github.com/wrfly/container-web-tty/container" 11 | pb "github.com/wrfly/container-web-tty/proxy/pb" 12 | "github.com/wrfly/container-web-tty/types" 13 | "github.com/wrfly/container-web-tty/util" 14 | ) 15 | 16 | const ( 17 | rpcCanceld = "rpc error: code = Canceled desc = context canceled" 18 | ) 19 | 20 | type containerService struct { 21 | cli container.Cli 22 | auth string 23 | } 24 | 25 | func newContainerService(cli container.Cli, auth string) pb.ContainerServerServer { 26 | return &containerService{ 27 | cli: cli, 28 | auth: auth, 29 | } 30 | } 31 | 32 | func (svc *containerService) checkAuth(auth string) error { 33 | if auth != svc.auth { 34 | return fmt.Errorf("auth failed") 35 | } 36 | return nil 37 | } 38 | 39 | func (svc *containerService) wrapContainer(cs ...types.Container) []*pb.Container { 40 | pbContainers := make([]*pb.Container, 0, len(cs)) 41 | for _, c := range cs { 42 | pbContainers = append(pbContainers, util.ConvertTpContainer(c)) 43 | } 44 | return pbContainers 45 | } 46 | 47 | func checkNil(x interface{}) error { 48 | if x == nil { 49 | return fmt.Errorf("nil pointer") 50 | } 51 | return nil 52 | } 53 | 54 | func (svc *containerService) Ping(ctx context.Context, e *pb.Empty) (*pb.Pong, error) { 55 | if err := checkNil(e); err != nil { 56 | return nil, err 57 | } 58 | if err := svc.checkAuth(e.Auth); err != nil { 59 | return nil, err 60 | } 61 | 62 | return &pb.Pong{ 63 | Msg: "pong", 64 | }, nil 65 | } 66 | 67 | func (svc *containerService) GetInfo(ctx context.Context, cid *pb.ContainerID) (*pb.Container, error) { 68 | if err := checkNil(cid); err != nil { 69 | return nil, err 70 | } 71 | 72 | if err := svc.checkAuth(cid.Auth); err != nil { 73 | return nil, err 74 | } 75 | 76 | c := svc.wrapContainer(svc.cli.GetInfo(ctx, cid.Id))[0] 77 | logrus.Debugf("get info of container: %s (%s)", c.Id, c.Shell) 78 | return c, nil 79 | } 80 | 81 | func (svc *containerService) List(ctx context.Context, e *pb.Empty) (*pb.Containers, error) { 82 | if err := checkNil(e); err != nil { 83 | return nil, err 84 | } 85 | 86 | if err := svc.checkAuth(e.Auth); err != nil { 87 | return nil, err 88 | } 89 | 90 | return &pb.Containers{ 91 | Cs: svc.wrapContainer(svc.cli.List(ctx)...), 92 | }, nil 93 | } 94 | 95 | func (svc *containerService) Start(ctx context.Context, cid *pb.ContainerID) (*pb.Err, error) { 96 | if err := checkNil(cid); err != nil { 97 | return nil, err 98 | } 99 | 100 | if err := svc.checkAuth(cid.Auth); err != nil { 101 | return nil, err 102 | } 103 | 104 | logrus.Debugf("start container: %s", cid.Id) 105 | err := svc.cli.Start(ctx, cid.Id) 106 | if err == nil { 107 | return nil, nil 108 | } 109 | return &pb.Err{ 110 | Err: err.Error(), 111 | }, nil 112 | } 113 | 114 | func (svc *containerService) Stop(ctx context.Context, cid *pb.ContainerID) (*pb.Err, error) { 115 | if err := checkNil(cid); err != nil { 116 | return nil, err 117 | } 118 | 119 | if err := svc.checkAuth(cid.Auth); err != nil { 120 | return nil, err 121 | } 122 | 123 | logrus.Debugf("stop container: %s", cid.Id) 124 | err := svc.cli.Stop(ctx, cid.Id) 125 | if err == nil { 126 | return nil, nil 127 | } 128 | return &pb.Err{ 129 | Err: err.Error(), 130 | }, nil 131 | } 132 | 133 | func (svc *containerService) Restart(ctx context.Context, cid *pb.ContainerID) (*pb.Err, error) { 134 | if err := checkNil(cid); err != nil { 135 | return nil, err 136 | } 137 | 138 | if err := svc.checkAuth(cid.Auth); err != nil { 139 | return nil, err 140 | } 141 | 142 | logrus.Debugf("restart container: %s", cid.Id) 143 | err := svc.cli.Restart(ctx, cid.Id) 144 | if err == nil { 145 | return nil, nil 146 | } 147 | return &pb.Err{ 148 | Err: err.Error(), 149 | }, nil 150 | } 151 | 152 | func (svc *containerService) Exec(stream pb.ContainerServer_ExecServer) error { 153 | // get the initial command and auth and container info 154 | execOpts, err := stream.Recv() 155 | if err != nil { 156 | return err 157 | } 158 | if err := svc.checkAuth(execOpts.Auth); err != nil { 159 | return err 160 | } 161 | if execOpts.C == nil { 162 | return fmt.Errorf("nil container") 163 | } 164 | logrus.Debugf("grpc server exec into container: %s", execOpts.C.Id) 165 | ctx, cancel := context.WithCancel(context.Background()) 166 | defer cancel() 167 | container := util.ConvertPbContainer(execOpts.C) 168 | logrus.Debugf("container info: %v", container) 169 | tty, err := svc.cli.Exec(ctx, container) 170 | if err != nil { 171 | logrus.Errorf("grpc server exec error: %s", err) 172 | return err 173 | } 174 | defer tty.Exit() 175 | 176 | go func() { 177 | for { 178 | execOpts, err := stream.Recv() 179 | if err != nil { 180 | if err.Error() == rpcCanceld || err == io.EOF { 181 | break 182 | } 183 | logrus.Debugf("grpc receive outputs error: %s", err) 184 | break 185 | } 186 | if execOpts == nil { 187 | continue 188 | } 189 | // resize terminal 190 | if ws := execOpts.Ws; ws != nil { 191 | logrus.Debugf("resize window to %dx%d", ws.Width, ws.Height) 192 | tty.ResizeTerminal(int(ws.Width), int(ws.Height)) 193 | continue 194 | } 195 | // logrus.Debugf("tty write: %s", execOpts.Cmd.In) 196 | _, err = tty.Write(execOpts.Cmd.In) 197 | if err == io.EOF { 198 | continue 199 | } 200 | if err != nil { 201 | logrus.Debugf("tty write error: %s", err) 202 | break 203 | } 204 | } 205 | logrus.Debugf("grpc server receive done, break") 206 | }() 207 | 208 | bs := make([]byte, 1024) 209 | for { 210 | n, err := tty.Read(bs) 211 | if err == io.EOF { 212 | break 213 | } 214 | // logrus.Debugf("tty read: %s", bs[:n]) 215 | err = stream.Send(&pb.ExecOptions{ 216 | Cmd: &pb.Io{ 217 | Out: bs[:n], 218 | }, 219 | }) 220 | if err == io.EOF { 221 | continue 222 | } 223 | if err != nil { 224 | if err.Error() != rpcCanceld { 225 | logrus.Debugf("grpc send command error: %s", err) 226 | } 227 | break 228 | } 229 | } 230 | logrus.Debugf("tty read done, break") 231 | 232 | logrus.Debugf("grpc exec done") 233 | return nil 234 | } 235 | 236 | func (svc *containerService) Logs(logOpts *pb.LogOpts, stream pb.ContainerServer_LogsServer) error { 237 | cid := logOpts.C 238 | if err := checkNil(cid); err != nil { 239 | return err 240 | } 241 | 242 | if err := svc.checkAuth(cid.Auth); err != nil { 243 | return err 244 | } 245 | 246 | logrus.Debugf("get container logs: %s", cid.Id) 247 | rc, err := svc.cli.Logs(stream.Context(), types.LogOptions{ 248 | Follow: logOpts.Follow, 249 | Tail: logOpts.Tail, 250 | ID: cid.Id, 251 | }) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | buff := make([]byte, 2048) 257 | for { 258 | n, err := rc.Read(buff) 259 | if err != nil { 260 | if err != context.Canceled { 261 | logrus.Errorf("read logs error: %s", err) 262 | } 263 | break 264 | } 265 | if err := stream.Send(&pb.Io{ 266 | In: buff[:n], 267 | }); err != nil { 268 | return err 269 | } 270 | } 271 | 272 | return nil 273 | } 274 | -------------------------------------------------------------------------------- /resources/control.js: -------------------------------------------------------------------------------- 1 | // container control 2 | 3 | try { 4 | var htmlBtns = document.getElementsByTagName('button'); 5 | for (var i = 0; i < htmlBtns.length; ++i) { 6 | htmlBtns[i].onclick = function () { 7 | var cid = this.parentElement.parentElement.querySelector('a').getAttribute('value'); 8 | var action = this.title; 9 | var u = "/container/" + action + "/" + cid; 10 | var xmlhttp = new XMLHttpRequest(); 11 | xmlhttp.open("POST", u); 12 | xmlhttp.onreadystatechange = function () { 13 | if (xmlhttp.readyState == 4) { 14 | var j = JSON.parse(xmlhttp.responseText); 15 | console.debug(j); 16 | if (xmlhttp.status != 200) { 17 | alert(xmlhttp.responseText); 18 | } 19 | } 20 | }; 21 | alert(action + " container " + cid.substring(0, 8)); 22 | console.debug("POST: " + u); 23 | xmlhttp.send(); 24 | }; 25 | } 26 | } catch (error) { 27 | console.error(error); 28 | } -------------------------------------------------------------------------------- /resources/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wrfly/container-web-tty/2c216335d90866705a530d877ef685ca96676429/resources/favicon.png -------------------------------------------------------------------------------- /resources/index.css: -------------------------------------------------------------------------------- 1 | html, body, #terminal { 2 | background: black; 3 | height: 100%; 4 | width: 100%; 5 | padding: 0%; 6 | margin: 0%; 7 | } -------------------------------------------------------------------------------- /resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{.title}} 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 23 | 24 | -------------------------------------------------------------------------------- /resources/list.css: -------------------------------------------------------------------------------- 1 | /*////////////////////////////////////////////////////////////////// 2 | [ RESTYLE TAG ]*/ 3 | * { 4 | margin: 0px; 5 | padding: 0px; 6 | box-sizing: border-box; 7 | } 8 | 9 | body, html { 10 | height: 100%; 11 | font-family: sans-serif; 12 | background-color: #393939; 13 | } 14 | 15 | /* ------------------------------------ */ 16 | a { 17 | margin: 0px; 18 | transition: all 0.4s; 19 | -webkit-transition: all 0.4s; 20 | -o-transition: all 0.4s; 21 | text-decoration: none; 22 | -moz-transition: all 0.4s; 23 | color: rgb(119, 60, 168); 24 | } 25 | 26 | a:hover { 27 | outline: none !important; 28 | color: #17e919; 29 | } 30 | 31 | 32 | /* ------------------------------------ */ 33 | h1,h2,h3,h4,h5,h6 {margin: 0px;} 34 | 35 | p {margin: 0px;} 36 | 37 | ul, li { 38 | margin: 0px; 39 | list-style-type: none; 40 | } 41 | 42 | 43 | /* ------------------------------------ */ 44 | input { 45 | display: block; 46 | outline: none; 47 | border: none !important; 48 | } 49 | 50 | textarea { 51 | display: block; 52 | outline: none; 53 | } 54 | 55 | textarea:focus, input:focus { 56 | border-color: transparent !important; 57 | } 58 | 59 | /* ------------------------------------ */ 60 | button { 61 | outline: none !important; 62 | border: none; 63 | background: transparent; 64 | } 65 | 66 | button:hover { 67 | cursor: pointer; 68 | } 69 | 70 | iframe { 71 | border: none !important; 72 | } 73 | 74 | 75 | .container-table { 76 | width: 100%; 77 | min-height: 100vh; 78 | background: #fff; 79 | 80 | display: -webkit-box; 81 | display: -webkit-flex; 82 | display: -moz-box; 83 | display: -ms-flexbox; 84 | display: flex; 85 | align-items: center; 86 | justify-content: center; 87 | flex-wrap: wrap; 88 | padding: 33px 30px; 89 | } 90 | 91 | .wrap-table { 92 | width: 1170px; 93 | } 94 | 95 | 96 | table { 97 | width: 100%; 98 | border-collapse: collapse; 99 | table-layout:fixed; 100 | } 101 | 102 | button{ 103 | color: #de901c; 104 | } 105 | 106 | th, td { 107 | font-weight: unset; 108 | padding-right: 20px; 109 | padding-left: 20px; 110 | text-align: center; 111 | white-space: nowrap; 112 | overflow: hidden; 113 | } 114 | 115 | /* ID */ 116 | .column1 { 117 | width: 15%; 118 | padding-left: 40px; 119 | } 120 | 121 | /* Image */ 122 | .column2 { 123 | width: 25%; 124 | } 125 | 126 | /* Command */ 127 | .column3 { 128 | width: 15%; 129 | } 130 | 131 | /* Name */ 132 | .column4 { 133 | width: 15%; 134 | } 135 | 136 | /* IP */ 137 | .column5 { 138 | width: 12%; 139 | } 140 | 141 | /* LocServer */ 142 | .column6 { 143 | width: 10%; 144 | } 145 | 146 | /* Status */ 147 | .column7 { 148 | width: 10%; 149 | padding-right: 30px; 150 | } 151 | 152 | /* Buttons */ 153 | .column8 { 154 | width: 15%; 155 | padding-right: 20px; 156 | } 157 | 158 | .table-head th { 159 | padding-top: 18px; 160 | padding-bottom: 18px; 161 | } 162 | 163 | .table-body td { 164 | padding-top: 16px; 165 | padding-bottom: 16px; 166 | } 167 | 168 | /*================================================================== 169 | [ Fix header ]*/ 170 | .table { 171 | position: relative; 172 | padding-top: 60px; 173 | background-color: #393939; 174 | } 175 | 176 | .table-head { 177 | position: absolute; 178 | width: 100%; 179 | top: 0; 180 | left: 0; 181 | } 182 | 183 | .table.ver3 th { 184 | font-family: Lato-Bold; 185 | font-size: 15px; 186 | color: #00ad5f; 187 | line-height: 1.4; 188 | text-transform: uppercase; 189 | background-color: #393939; 190 | } 191 | 192 | .t.c.ver2 td { 193 | background-color: #171717; 194 | } 195 | 196 | .table.ver3 td { 197 | font-family: Lato-Regular; 198 | font-size: 15px; 199 | color: #717171; 200 | line-height: 1.4; 201 | background-color: #00000082; 202 | } 203 | -------------------------------------------------------------------------------- /resources/list.html: -------------------------------------------------------------------------------- 1 | {{- $ctl := .control -}} {{- $showLocation := .loc -}} {{- $share := .share -}} {{- $base := .base -}} 2 | 3 | 4 | 5 | 6 | {{ .title }} 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {{- if $showLocation }} 23 | 24 | {{- end }} 25 | 26 | {{- if $ctl.Enable }} 27 | 28 | {{- end -}} 29 | 30 | 31 |
Container IDImageCommandNameIPLocationStatusActions
32 |
33 | 34 |
35 | 36 | 37 | {{ range $i, $e := .containers }} 38 | 39 | 42 | 43 | 44 | 47 | 48 | {{- if $showLocation -}} 49 | 50 | {{- end -}} 51 | 52 | {{ if $ctl.Enable -}} 53 | 59 | {{ end -}} 60 | 61 | {{ end }} 62 | 63 |
40 | {{ printf "%.12s" .ID }} 41 | {{ printf .Image }}{{ printf .Command }} 45 | {{ printf .Name }} 46 | {{ index .IPs 0 }}{{ printf .LocServer }}{{ .Status }} 54 | {{ if or $ctl.Start $ctl.All }} 55 | {{ end }} {{ if or $ctl.Stop $ctl.All }} 56 | {{ end }} {{ if or $ctl.Restart $ctl.All}} 57 | {{ end }} 58 |
64 |
65 |
66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /resources/xterm_customize.css: -------------------------------------------------------------------------------- 1 | .terminal { 2 | font-family: "DejaVu Sans Mono", "Everson Mono", FreeMono, Menlo, Terminal, monospace, "Apple Symbols"; 3 | } 4 | 5 | .xterm-overlay { 6 | font-family: "DejaVu Sans Mono", "Everson Mono", FreeMono, Menlo, Terminal, monospace, "Apple Symbols"; 7 | border-radius: 15px; 8 | font-size: xx-large; 9 | color: black; 10 | background: white; 11 | opacity: 0.75; 12 | padding: 0.2em 0.5em 0.2em 0.5em; 13 | position: absolute; 14 | top: 50%; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | user-select: none; 18 | transition: opacity 180ms ease-in; 19 | } -------------------------------------------------------------------------------- /route/asset/bindata.go: -------------------------------------------------------------------------------- 1 | package asset 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "errors" 7 | "fmt" 8 | "html/template" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path/filepath" 15 | "sort" 16 | "time" 17 | ) 18 | 19 | type Assets interface { 20 | List() []Asset // return all files 21 | Find(name string) (Asset, error) 22 | ServeHTTP(w http.ResponseWriter, r *http.Request) // implement http.FileServer 23 | } 24 | 25 | type Asset interface { 26 | List() ([]Asset, error) 27 | Bytes() []byte // return file bytes 28 | Name() string // return file serve name 29 | http.File // implement http.FileSystem 30 | Template() *template.Template 31 | } 32 | 33 | var ( 34 | errSeekInvalid = errors.New("invalid whence") 35 | errSeekNegative = errors.New("negative position") 36 | errNotDir = errors.New("file is not a dir") 37 | ) 38 | 39 | type data struct { 40 | prefix string 41 | files map[string]*file 42 | all *file 43 | } 44 | 45 | func (d *data) Find(name string) (Asset, error) { 46 | if f, found := d.files[name]; found { 47 | return &fileReader{f, 0}, nil 48 | } 49 | return nil, os.ErrNotExist 50 | } 51 | 52 | func (d *data) List() []Asset { 53 | return d.all.assets 54 | } 55 | 56 | func (d *data) ServeHTTP(w http.ResponseWriter, r *http.Request) { 57 | if f, found := d.files[r.RequestURI]; found { 58 | index, indexFound := d.files[filepath.Join(r.RequestURI, "index.html")] 59 | if indexFound { 60 | f = index 61 | } else if f.isDir { 62 | dirList(w, r, &fileReader{f, 0}) 63 | return 64 | } 65 | w.Header().Set("Content-Length", fmt.Sprint(f.size)) 66 | w.Header().Set("Content-Type", f.cType) 67 | w.Header().Set("Date", fmt.Sprint(f.mTime)) 68 | w.Write(f.b) 69 | } else { 70 | w.WriteHeader(http.StatusNotFound) 71 | } 72 | } 73 | 74 | type fileReader struct { 75 | *file 76 | i int64 77 | } 78 | 79 | func (r *fileReader) Read(p []byte) (n int, err error) { 80 | if r.i >= r.size { 81 | return 0, io.EOF 82 | } 83 | n = copy(p, r.file.b[r.i:]) 84 | r.i += int64(n) 85 | return 86 | } 87 | 88 | func (r *fileReader) Seek(offset int64, whence int) (int64, error) { 89 | var abs int64 90 | switch whence { 91 | case io.SeekStart: 92 | abs = offset 93 | case io.SeekCurrent: 94 | abs = r.i + offset 95 | case io.SeekEnd: 96 | abs = r.size + offset 97 | default: 98 | return 0, errSeekInvalid 99 | } 100 | if abs < 0 { 101 | return 0, errSeekNegative 102 | } 103 | r.i = abs 104 | return abs, nil 105 | } 106 | 107 | func (r *fileReader) Close() error { 108 | return nil 109 | } 110 | 111 | type file struct { 112 | *fileInfo 113 | id int // file id (depth_num) 114 | path string // path 115 | dirP string // dir path 116 | sPath string // serve path 117 | b []byte // data 118 | cb []byte // data 119 | infos []os.FileInfo 120 | files []*file 121 | assets []Asset 122 | } 123 | 124 | func (f *file) Readdir(count int) ([]os.FileInfo, error) { 125 | if !f.isDir { 126 | return nil, errors.New("not dir") 127 | } 128 | if count < 0 { 129 | return f.infos, nil 130 | } 131 | if count >= len(f.infos) { 132 | count = len(f.infos) - 1 133 | } 134 | return f.infos[:count], nil 135 | } 136 | 137 | func (f *file) Stat() (os.FileInfo, error) { 138 | return f, nil 139 | } 140 | 141 | func (f *file) File() (http.File, error) { 142 | return &fileReader{f, 0}, nil 143 | } 144 | 145 | func (f *file) Bytes() []byte { 146 | return f.b 147 | } 148 | 149 | func (f *file) Name() string { 150 | return f.sPath 151 | } 152 | 153 | func (f *file) List() ([]Asset, error) { 154 | if f.isDir { 155 | return f.assets, nil 156 | } 157 | return nil, errNotDir 158 | } 159 | 160 | func (f *file) Template() *template.Template { 161 | t, err := template.New(f.name).Parse(string(f.b)) 162 | if err != nil { 163 | panic(err) 164 | } 165 | return t 166 | } 167 | 168 | func (f *file) keyFileName() string { 169 | return fmt.Sprintf("_file_%d", f.id) 170 | } 171 | 172 | func (f *file) keyBytesName() string { 173 | return fmt.Sprintf("_compress_bytes_%d", f.id) 174 | } 175 | 176 | func (f *file) keyMTime() string { 177 | return fmt.Sprintf("_mTime_%d", f.id) 178 | } 179 | 180 | type fileInfo struct { 181 | name string 182 | isDir bool 183 | size int64 184 | mode os.FileMode 185 | mTime time.Time 186 | cType string 187 | } 188 | 189 | // base name of the file 190 | func (f *fileInfo) Name() string { 191 | return f.name 192 | } 193 | 194 | // length in bytes for regular files; system-dependent for others 195 | func (f *fileInfo) Size() int64 { 196 | return f.size 197 | } 198 | 199 | // file mode bits 200 | func (f *fileInfo) Mode() os.FileMode { 201 | return f.mode 202 | } 203 | 204 | // modification time 205 | func (f *fileInfo) ModTime() time.Time { 206 | return f.mTime 207 | } 208 | 209 | // abbreviation for Mode().IsDir() 210 | func (f *fileInfo) IsDir() bool { 211 | return f.isDir 212 | } 213 | 214 | // underlying data source (can return nil) 215 | func (f *fileInfo) Sys() interface{} { 216 | return nil 217 | } 218 | 219 | // dirList copied from http.FileSystem 220 | func dirList(w http.ResponseWriter, r *http.Request, f http.File) { 221 | dirs, err := f.Readdir(-1) 222 | if err != nil { 223 | w.WriteHeader(http.StatusInternalServerError) 224 | fmt.Fprint(w, "Error reading directory") 225 | return 226 | } 227 | sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() }) 228 | 229 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 230 | fmt.Fprintf(w, "
\n")
231 | 	for _, d := range dirs {
232 | 		name := d.Name()
233 | 		if d.IsDir() {
234 | 			name += "/"
235 | 		}
236 | 		// name may contain '?' or '#', which must be escaped to remain
237 | 		// part of the URL path, and not indicate the start of a query
238 | 		// string or fragment.
239 | 		url := url.URL{Path: filepath.Join(r.RequestURI, name)}
240 | 		fmt.Fprintf(w, "%s\n", url.String(), d.Name())
241 | 	}
242 | 	fmt.Fprintf(w, "
\n") 243 | } 244 | 245 | func unCompress(in []byte) []byte { 246 | r := bytes.NewBuffer(in) 247 | zr, _ := zlib.NewReader(r) 248 | defer zr.Close() 249 | bs, _ := ioutil.ReadAll(zr) 250 | return bs 251 | } 252 | 253 | var ( 254 | // Root asset interface 255 | Root Assets 256 | fs []*file 257 | root = &data{} 258 | ) 259 | 260 | func init() { 261 | for _, f := range fs { 262 | f.b = unCompress(f.cb) 263 | if !f.isDir || len(f.files) != 0 { 264 | continue 265 | } 266 | for _, ff := range fs { 267 | if ff.dirP == f.path { 268 | f.infos = append(f.infos, ff.fileInfo) 269 | f.files = append(f.files, ff) 270 | f.assets = append(f.assets, &fileReader{ff, 0}) 271 | } 272 | } 273 | } 274 | 275 | all := &file{fileInfo: &fileInfo{isDir: true}} 276 | for _, f := range fs { 277 | if f.IsDir() { 278 | root.files[f.sPath+"/"] = f 279 | } 280 | root.files[f.sPath] = f 281 | root.files[f.path] = f 282 | all.files = append(all.files, f) 283 | all.infos = append(all.infos, f.fileInfo) 284 | all.assets = append(all.assets, &fileReader{f, 0}) 285 | } 286 | root.all = all 287 | Root = root 288 | } 289 | 290 | func List() []Asset { 291 | return root.List() 292 | } 293 | 294 | func Find(name string) (Asset, error) { 295 | return root.Find(name) 296 | } 297 | 298 | var Handler = func(w http.ResponseWriter, r *http.Request) { 299 | root.ServeHTTP(w, r) 300 | } 301 | -------------------------------------------------------------------------------- /route/exec.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/wrfly/container-web-tty/third-part/gotty/webtty" 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/gorilla/websocket" 15 | log "github.com/sirupsen/logrus" 16 | "github.com/wrfly/container-web-tty/audit" 17 | "github.com/wrfly/container-web-tty/types" 18 | ) 19 | 20 | func (server *Server) handleExecRedirect(c *gin.Context) { 21 | containerID := c.Param("cid") 22 | execID := server.setContainerID(containerID) 23 | base := filepath.Join(server.options.Base, "/exec/") + "/" 24 | if query := c.Request.URL.RawQuery; query != "" { 25 | c.Redirect(302, base+execID+"?"+c.Request.URL.RawQuery) 26 | } else { 27 | c.Redirect(302, base+execID) 28 | } 29 | } 30 | 31 | func (server *Server) handleExec(c *gin.Context, counter *counter) { 32 | execID := c.Param("eid") 33 | containerID, ok := server.getContainerID(execID) 34 | if !ok { 35 | c.String(http.StatusBadRequest, fmt.Sprintf("exec id %s not found", execID)) 36 | return 37 | } 38 | 39 | server.m.RLock() 40 | masterTTY, ok := server.masters[execID] 41 | server.m.RUnlock() 42 | if ok { // exec ID exist, use the same master 43 | log.Infof("using exist master for exec %s", execID) 44 | server.processShare(c, execID, masterTTY) 45 | return 46 | } 47 | 48 | cInfo := server.containerCli.GetInfo(c.Request.Context(), containerID) 49 | server.generateHandleWS(c.Request.Context(), execID, counter, cInfo). 50 | ServeHTTP(c.Writer, c.Request) 51 | } 52 | 53 | func (server *Server) generateHandleWS(ctx context.Context, execID string, counter *counter, container types.Container) http.HandlerFunc { 54 | return func(w http.ResponseWriter, r *http.Request) { 55 | if container.Shell == "" { 56 | log.Errorf("cannot find a valid shell in container [%s]", container.ID) 57 | return 58 | } 59 | 60 | num := counter.add(1) 61 | closeReason := "unknown reason" 62 | 63 | defer func() { 64 | num := counter.done() 65 | if strings.Contains(closeReason, "error") { 66 | log.Errorf("Connection closed by %s: %s, connections: %d", 67 | closeReason, r.RemoteAddr, num) 68 | } 69 | log.Infof("Connection closed by %s: %s, connections: %d", 70 | closeReason, r.RemoteAddr, num) 71 | }() 72 | 73 | if int64(server.options.MaxConnection) != 0 { 74 | if num > server.options.MaxConnection { 75 | closeReason = "exceeding max number of connections" 76 | return 77 | } 78 | } 79 | 80 | log.Infof("New client connected: %s, connections: %d", r.RemoteAddr, num) 81 | 82 | conn, err := server.upgrader.Upgrade(w, r, nil) 83 | if err != nil { 84 | closeReason = err.Error() 85 | return 86 | } 87 | defer conn.Close() 88 | 89 | cctx, timeoutCancel := context.WithCancel(ctx) 90 | defer timeoutCancel() 91 | 92 | err = server.processTTY(cctx, execID, timeoutCancel, conn, container) 93 | switch err { 94 | case ctx.Err(): 95 | closeReason = "cancelation" 96 | case cctx.Err(): 97 | closeReason = "time out" 98 | case webtty.ErrSlaveClosed: 99 | closeReason = "backend closed" 100 | case webtty.ErrMasterClosed: 101 | closeReason = "tab closed" 102 | default: 103 | closeReason = fmt.Sprintf("an error: %s", err) 104 | } 105 | } 106 | } 107 | 108 | func (server *Server) processTTY(ctx context.Context, execID string, timeoutCancel context.CancelFunc, 109 | conn *websocket.Conn, container types.Container) error { 110 | arguments, err := server.readInitMessage(conn) 111 | if err != nil { 112 | return err 113 | } 114 | log.Debugf("exec container: %s, params: [%s]", container.ID[:7], arguments) 115 | 116 | q, err := parseQuery(strings.TrimSpace(arguments)) 117 | if err != nil { 118 | return err 119 | } 120 | container.Exec = types.ExecOptions{ 121 | Cmd: q.Get("cmd"), 122 | Env: q.Get("env"), 123 | User: q.Get("user"), 124 | Privileged: q.Get("p") != "", 125 | } 126 | 127 | containerTTY, err := server.containerCli.Exec(ctx, container) 128 | if err != nil { 129 | return fmt.Errorf("exec container error: %s", err) 130 | } 131 | defer func() { 132 | log.Infof("container %s exit", container.ID[:7]) 133 | if err := containerTTY.Exit(); err != nil { 134 | log.Warnf("exit container err: %s", err) 135 | } 136 | }() 137 | 138 | // handle timeout 139 | tout := server.options.IdleTime 140 | if tout.Seconds() != 0 { 141 | go func() { 142 | timer := time.NewTimer(tout) 143 | activeChan := containerTTY.ActiveChan() 144 | for { 145 | select { 146 | case <-timer.C: 147 | timer.Stop() 148 | timeoutCancel() 149 | return 150 | case _, ok := <-activeChan: 151 | if !ok { 152 | return 153 | } 154 | // the connection is active, reset the timer 155 | timer.Reset(tout) 156 | } 157 | } 158 | 159 | }() 160 | } 161 | 162 | titleBuf, err := server.makeTitleBuff(container) 163 | if err != nil { 164 | return fmt.Errorf("failed to fill window title template: %s", err) 165 | } 166 | 167 | opts := []webtty.Option{ 168 | webtty.WithWindowTitle(titleBuf), 169 | webtty.WithPermitWrite(), 170 | } 171 | 172 | shareID := fmt.Sprintf("%s-%d", container.ID, time.Now().UnixNano()) 173 | masterTTY, err := types.NewMasterTTY(ctx, containerTTY, shareID) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | server.m.Lock() 179 | server.masters[execID] = masterTTY 180 | server.m.Unlock() 181 | 182 | defer func() { 183 | // if master dead, all slaves dead 184 | server.m.Lock() 185 | masterTTY.Close() 186 | delete(server.masters, execID) 187 | delete(server.execs, execID) 188 | server.m.Unlock() 189 | }() 190 | 191 | if server.options.EnableAudit { 192 | go audit.LogTo(ctx, masterTTY.Fork(ctx, false), audit.LogOpts{ 193 | Dir: server.options.AuditLogDir, 194 | ContainerID: container.ID, 195 | ClientIP: conn.RemoteAddr().String(), 196 | }) 197 | } 198 | 199 | log.Infof("new web tty for container: %s", container.ID[:7]) 200 | wrapper := &wsWrapper{conn} 201 | tty, err := webtty.New(wrapper, masterTTY, opts...) 202 | if err != nil { 203 | return fmt.Errorf("failed to create webtty: %s", err) 204 | } 205 | 206 | return tty.Run(ctx) 207 | } 208 | -------------------------------------------------------------------------------- /route/handler.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/gorilla/websocket" 12 | log "github.com/sirupsen/logrus" 13 | 14 | "github.com/wrfly/container-web-tty/types" 15 | ) 16 | 17 | func (server *Server) handleAuthToken(c *gin.Context) { 18 | c.Header("Content-Type", "application/javascript") 19 | // @TODO hashing? 20 | c.String(200, "var gotty_auth_token = '%s';", server.options.Credential) 21 | } 22 | 23 | func (server *Server) handleConfig(c *gin.Context) { 24 | c.Header("Content-Type", "application/javascript") 25 | c.String(200, "var gotty_term = '%s';", server.options.Term) 26 | } 27 | 28 | // titleVariables merges maps in a specified order. 29 | // varUnits are name-keyed maps, whose names will be iterated using order. 30 | func (server *Server) titleVariables(order []string, varUnits map[string]map[string]interface{}) map[string]interface{} { 31 | titleVars := map[string]interface{}{} 32 | 33 | for _, name := range order { 34 | vars, ok := varUnits[name] 35 | if !ok { 36 | panic("title variable name error") 37 | } 38 | for key, val := range vars { 39 | titleVars[key] = val 40 | } 41 | } 42 | 43 | // safe net for conflicted keys 44 | for _, name := range order { 45 | titleVars[name] = varUnits[name] 46 | } 47 | 48 | return titleVars 49 | } 50 | 51 | func (server *Server) handleListContainers(c *gin.Context) { 52 | listVars := map[string]interface{}{ 53 | "title": "List Containers", 54 | "containers": server.containerCli.List(c.Request.Context()), 55 | "control": server.options.Control, 56 | "loc": server.options.ShowLocation, 57 | "base": strings.TrimSuffix(server.options.Base, "/"), 58 | } 59 | 60 | listBuf := new(bytes.Buffer) 61 | err := listTemplate.Execute(listBuf, listVars) 62 | if err != nil { 63 | c.Error(err) 64 | } 65 | 66 | c.Writer.Write(listBuf.Bytes()) 67 | } 68 | 69 | func (server *Server) handleContainerActions(c *gin.Context, action string) { 70 | cid := c.Param("id") 71 | log.Debugf("client [%s] is going to [%s] container [%s]", 72 | c.ClientIP(), action, cid) 73 | var err error 74 | switch action { 75 | case "start": 76 | err = server.containerCli.Start(c.Request.Context(), cid) 77 | case "stop": 78 | err = server.containerCli.Stop(c.Request.Context(), cid) 79 | case "restart": 80 | err = server.containerCli.Restart(c.Request.Context(), cid) 81 | } 82 | if err != nil { 83 | c.JSON(500, types.ContainerActionMessage{ 84 | Code: 500, 85 | Error: err.Error(), 86 | }) 87 | return 88 | } 89 | c.JSON(0, types.ContainerActionMessage{ 90 | Code: 0, 91 | Message: fmt.Sprintf("%s container %s successfully", action, cid[:7]), 92 | }) 93 | } 94 | 95 | func (server *Server) handleStartContainer(c *gin.Context) { 96 | server.handleContainerActions(c, "start") 97 | } 98 | 99 | func (server *Server) handleStopContainer(c *gin.Context) { 100 | server.handleContainerActions(c, "stop") 101 | } 102 | 103 | func (server *Server) handleRestartContainer(c *gin.Context) { 104 | server.handleContainerActions(c, "restart") 105 | } 106 | 107 | func (server *Server) makeTitleBuff(c types.Container, extra ...string) ([]byte, error) { 108 | location := "localhost" 109 | if c.LocServer != "" { 110 | location = c.LocServer 111 | } 112 | 113 | cName := c.Name 114 | if len(extra) != 0 { 115 | cName = extra[0] + " " + c.Name 116 | } 117 | 118 | titleVars := server.titleVariables( 119 | []string{"server"}, 120 | map[string]map[string]interface{}{ 121 | "server": { 122 | "containerLoc": location, 123 | "containerName": cName, 124 | "containerID": c.ID, 125 | }, 126 | }, 127 | ) 128 | titleBuf := new(bytes.Buffer) 129 | if err := titleTemplate.Execute(titleBuf, titleVars); err != nil { 130 | return nil, err 131 | } 132 | 133 | return titleBuf.Bytes(), nil 134 | } 135 | 136 | func (server *Server) readInitMessage(conn *websocket.Conn) (string, error) { 137 | typ, initLine, err := conn.ReadMessage() 138 | if err != nil { 139 | return "", fmt.Errorf("failed to authenticate websocket connection") 140 | } 141 | if typ != websocket.TextMessage { 142 | return "", fmt.Errorf("failed to authenticate websocket connection: invalid message type") 143 | } 144 | 145 | var init types.InitMessage 146 | if json.Unmarshal(initLine, &init) != nil { 147 | return "", fmt.Errorf("failed to authenticate websocket connection") 148 | } 149 | if server.options.Credential != "" && init.AuthToken != server.options.Credential { 150 | return "", fmt.Errorf("failed to authenticate websocket connection") 151 | } 152 | 153 | return init.Arguments, nil 154 | } 155 | 156 | func parseQuery(arguments string) (url.Values, error) { 157 | queryPath := "?" 158 | if arguments != "" { 159 | queryPath = arguments 160 | } 161 | refURL, err := url.Parse(queryPath) 162 | if err != nil { 163 | return nil, fmt.Errorf("bad arguments: %s", arguments) 164 | } 165 | return refURL.Query(), nil 166 | } 167 | -------------------------------------------------------------------------------- /route/handler_atomic.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type counter struct { 9 | duration time.Duration 10 | zeroTimer *time.Timer 11 | wg sync.WaitGroup 12 | connections int 13 | mutex sync.Mutex 14 | } 15 | 16 | func newCounter(duration time.Duration) *counter { 17 | zeroTimer := time.NewTimer(duration) 18 | 19 | // when duration is 0, drain the expire event here 20 | // so that user will never get the event. 21 | if duration == 0 { 22 | <-zeroTimer.C 23 | } 24 | 25 | return &counter{ 26 | duration: duration, 27 | zeroTimer: zeroTimer, 28 | } 29 | } 30 | 31 | func (counter *counter) add(n int) int { 32 | counter.mutex.Lock() 33 | defer counter.mutex.Unlock() 34 | 35 | if counter.duration > 0 { 36 | counter.zeroTimer.Stop() 37 | } 38 | counter.wg.Add(n) 39 | counter.connections += n 40 | 41 | return counter.connections 42 | } 43 | 44 | func (counter *counter) done() int { 45 | counter.mutex.Lock() 46 | defer counter.mutex.Unlock() 47 | 48 | counter.connections-- 49 | counter.wg.Done() 50 | if counter.connections == 0 && counter.duration > 0 { 51 | counter.zeroTimer.Reset(counter.duration) 52 | } 53 | 54 | return counter.connections 55 | } 56 | 57 | func (counter *counter) count() int { 58 | counter.mutex.Lock() 59 | defer counter.mutex.Unlock() 60 | 61 | return counter.connections 62 | } 63 | 64 | func (counter *counter) wait() { 65 | counter.wg.Wait() 66 | } 67 | 68 | func (counter *counter) timer() *time.Timer { 69 | return counter.zeroTimer 70 | } 71 | -------------------------------------------------------------------------------- /route/id.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import "github.com/wrfly/container-web-tty/util" 4 | 5 | func (server *Server) getContainerID(execID string) (string, bool) { 6 | server.m.Lock() 7 | containerID, ok := server.execs[execID] 8 | server.m.Unlock() 9 | return containerID, ok 10 | } 11 | 12 | func (server *Server) setContainerID(containerID string) string { 13 | execID := util.ID(containerID) 14 | server.m.Lock() 15 | server.execs[execID] = containerID 16 | server.m.Unlock() 17 | return execID 18 | } 19 | -------------------------------------------------------------------------------- /route/log.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/wrfly/container-web-tty/third-part/gotty/webtty" 7 | 8 | "github.com/gin-gonic/gin" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/wrfly/container-web-tty/types" 12 | "github.com/wrfly/container-web-tty/util" 13 | ) 14 | 15 | func (server *Server) handleLogs(c *gin.Context) { 16 | ctx := c.Request.Context() 17 | 18 | conn, err := server.upgrader.Upgrade(c.Writer, c.Request, nil) 19 | if err != nil { 20 | c.String(http.StatusInternalServerError, "server error: %s", err) 21 | return 22 | } 23 | defer conn.Close() 24 | 25 | initArg, err := server.readInitMessage(conn) 26 | if err != nil { 27 | c.String(http.StatusBadRequest, "read init message error: %s", err) 28 | return 29 | } 30 | 31 | q, err := parseQuery(initArg) 32 | if err != nil { 33 | c.String(http.StatusBadRequest, err.Error()) 34 | return 35 | } 36 | follow := true 37 | if v := q.Get("follow"); v != "1" && v != "" { 38 | follow = false 39 | } 40 | tail := "10" 41 | if v := q.Get("tail"); v != "" { 42 | tail = v 43 | } 44 | opts := types.LogOptions{ 45 | ID: c.Param("cid"), 46 | Follow: follow, 47 | Tail: tail, 48 | } 49 | 50 | container := server.containerCli.GetInfo(ctx, opts.ID) 51 | 52 | log.Debugf("get logs of container: %s", container.ID) 53 | logsReadCloser, err := server.containerCli.Logs(ctx, opts) 54 | if err != nil { 55 | c.String(http.StatusInternalServerError, "get logs error: %s", err) 56 | return 57 | } 58 | defer logsReadCloser.Close() 59 | 60 | titleBuf, err := server.makeTitleBuff(container) 61 | if err != nil { 62 | c.String(http.StatusInternalServerError, "failed to fill window title template: %s", err) 63 | return 64 | } 65 | 66 | tty, err := webtty.New( 67 | &wsWrapper{conn}, 68 | newSlave(util.NopRWCloser(logsReadCloser)), 69 | []webtty.Option{ 70 | webtty.WithWindowTitle(titleBuf), 71 | webtty.WithPermitWrite(), // can type "enter" 72 | }..., 73 | ) 74 | if err != nil { 75 | c.String(http.StatusInternalServerError, "failed to create webtty: %s", err) 76 | return 77 | } 78 | 79 | if err := tty.Run(ctx); err != nil { 80 | if err != webtty.ErrMasterClosed && err != webtty.ErrSlaveClosed { 81 | log.Errorf("failed to run webtty: %s", err) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /route/route.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "net" 8 | "net/http" 9 | pprof "net/http/pprof" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | "sync" 15 | noesctmpl "text/template" 16 | "time" 17 | 18 | "github.com/wrfly/container-web-tty/third-part/gotty/webtty" 19 | 20 | "github.com/gin-gonic/gin" 21 | "github.com/gorilla/websocket" 22 | log "github.com/sirupsen/logrus" 23 | 24 | "github.com/wrfly/container-web-tty/config" 25 | "github.com/wrfly/container-web-tty/container" 26 | "github.com/wrfly/container-web-tty/route/asset" 27 | "github.com/wrfly/container-web-tty/types" 28 | ) 29 | 30 | // Server provides a webtty HTTP endpoint. 31 | type Server struct { 32 | options config.ServerConfig 33 | containerCli container.Cli 34 | upgrader *websocket.Upgrader 35 | srv *http.Server 36 | hostname string 37 | 38 | // execID -> containerID 39 | execs map[string]string 40 | // execID -> process 41 | masters map[string]*types.MasterTTY 42 | m sync.RWMutex 43 | } 44 | 45 | var ( 46 | indexTemplate *template.Template 47 | listTemplate *template.Template 48 | titleTemplate *noesctmpl.Template 49 | ) 50 | 51 | func mod(i, j int) int { 52 | return i % j 53 | } 54 | 55 | func init() { 56 | indexData, err := asset.Find("/index.html") 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | indexTemplate = indexData.Template() 61 | 62 | listIndexData, err := asset.Find("/list.html") 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | 67 | listTemplate, err = template.New(listIndexData.Name()). 68 | Funcs(template.FuncMap{ 69 | "mod": mod, 70 | }).Parse(string(listIndexData.Bytes())) 71 | if err != nil { 72 | panic(err) 73 | } 74 | 75 | titleFormat := "{{ .containerName }}@{{ .containerLoc }}" 76 | titleTemplate, err = noesctmpl.New("title").Parse(titleFormat) 77 | if err != nil { 78 | log.Fatal(err) 79 | } 80 | } 81 | 82 | // New creates a new instance of Server. 83 | // Server will use the New() of the factory provided to handle each request. 84 | func New(containerCli container.Cli, options config.ServerConfig) (*Server, error) { 85 | 86 | var originChekcer func(r *http.Request) bool 87 | if options.WSOrigin != "" { 88 | matcher, err := regexp.Compile(options.WSOrigin) 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to compile regular expression of Websocket Origin: %s", options.WSOrigin) 91 | } 92 | originChekcer = func(r *http.Request) bool { 93 | return matcher.MatchString(r.Header.Get("Origin")) 94 | } 95 | } 96 | 97 | h, _ := os.Hostname() 98 | return &Server{ 99 | options: options, 100 | containerCli: containerCli, 101 | execs: make(map[string]string, 500), 102 | masters: make(map[string]*types.MasterTTY, 50), 103 | hostname: h, 104 | 105 | upgrader: &websocket.Upgrader{ 106 | ReadBufferSize: 1024, 107 | WriteBufferSize: 1024, 108 | Subprotocols: webtty.Protocols, 109 | CheckOrigin: originChekcer, 110 | }, 111 | }, nil 112 | } 113 | 114 | // Run starts the main process of the Server. 115 | // The cancelation of ctx will shutdown the server immediately with aborting 116 | // existing connections. Use WithGracefulContext() to support graceful shutdown. 117 | func (server *Server) Run(ctx context.Context, options ...RunOption) error { 118 | cctx, cancel := context.WithCancel(ctx) 119 | defer cancel() 120 | 121 | opts := &RunOptions{gracefulCtx: context.Background()} 122 | for _, opt := range options { 123 | opt(opts) 124 | } 125 | 126 | router := gin.New() 127 | router.Use(gin.Recovery()) 128 | if gin.Mode() == gin.DebugMode { 129 | router.Use(gin.Logger()) 130 | } 131 | 132 | base := filepath.Join(server.options.Base) 133 | api := router.Group(base) 134 | 135 | // Routes 136 | api.GET("/", server.handleListContainers) 137 | api.GET("/auth_token.js", server.handleAuthToken) 138 | api.GET("/config.js", server.handleConfig) 139 | 140 | rewriteH := func(c *gin.Context) { 141 | if base != "/" { 142 | c.Request.RequestURI = strings.TrimPrefix( 143 | c.Request.RequestURI, base, 144 | ) 145 | } 146 | asset.Handler(c.Writer, c.Request) 147 | } 148 | for _, f := range asset.List() { 149 | if f.Name() != "/" { 150 | api.GET(f.Name(), rewriteH) 151 | } 152 | } 153 | 154 | // exec 155 | counter := newCounter(server.options.IdleTime) 156 | api.GET("/e/:cid/", server.handleExecRedirect) // containerID 157 | api.GET("/exec/:eid/", server.handleWSIndex) // execID 158 | api.GET("/exec/:eid/"+"ws", func(c *gin.Context) { server.handleExec(c, counter) }) 159 | 160 | // logs 161 | api.GET("/logs/:cid/", server.handleWSIndex) 162 | api.GET("/logs/:cid/"+"ws", func(c *gin.Context) { server.handleLogs(c) }) 163 | 164 | ctl := server.options.Control 165 | if ctl.Enable { 166 | // container actions: start|stop|restart 167 | containerG := api.Group("/container") 168 | if ctl.Start || ctl.All { 169 | containerG.POST("/start/:id", server.handleStartContainer) 170 | } 171 | if ctl.Stop || ctl.All { 172 | containerG.POST("/stop/:id", server.handleStopContainer) 173 | } 174 | if ctl.Restart || ctl.All { 175 | containerG.POST("/restart/:id", server.handleRestartContainer) 176 | } 177 | } 178 | 179 | // pprof 180 | rootMux := http.NewServeMux() 181 | if log.GetLevel() == log.DebugLevel { 182 | rootMux.HandleFunc(filepath.Join(base, "/debug/pprof/"), pprof.Index) 183 | rootMux.HandleFunc(filepath.Join(base, "/debug/pprof/cmdline"), pprof.Cmdline) 184 | rootMux.HandleFunc(filepath.Join(base, "/debug/pprof/profile"), pprof.Profile) 185 | rootMux.HandleFunc(filepath.Join(base, "/debug/pprof/symbol"), pprof.Symbol) 186 | rootMux.HandleFunc(filepath.Join(base, "/debug/pprof/trace"), pprof.Trace) 187 | } 188 | rootMux.Handle("/", router) 189 | 190 | hostPort := net.JoinHostPort(server.options.Address, 191 | fmt.Sprint(server.options.Port)) 192 | srv := &http.Server{ 193 | Addr: hostPort, 194 | Handler: rootMux, 195 | } 196 | 197 | srvErr := make(chan error, 1) 198 | go func() { 199 | srvErr <- srv.ListenAndServe() 200 | }() 201 | 202 | go func() { 203 | select { 204 | case <-opts.gracefulCtx.Done(): 205 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 206 | defer cancel() 207 | if err := srv.Shutdown(ctx); err != nil { 208 | log.Fatal("Server Shutdown:", err) 209 | } 210 | case <-cctx.Done(): 211 | } 212 | }() 213 | 214 | log.Infof("Server running at http://%s%s", hostPort, base) 215 | 216 | var err error 217 | select { 218 | case err = <-srvErr: 219 | if err == http.ErrServerClosed { // by graceful ctx 220 | err = nil 221 | } else { 222 | cancel() 223 | } 224 | case <-cctx.Done(): 225 | srv.Close() 226 | err = cctx.Err() 227 | } 228 | 229 | conn := counter.count() 230 | if conn > 0 { 231 | log.Printf("Waiting for %d connections to be closed", conn) 232 | fmt.Println("Ctl-C to force close") 233 | } 234 | counter.wait() 235 | 236 | return err 237 | } 238 | -------------------------------------------------------------------------------- /route/share.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wrfly/container-web-tty/third-part/gotty/webtty" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/gorilla/websocket" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/wrfly/container-web-tty/types" 12 | ) 13 | 14 | func (server *Server) processShare(c *gin.Context, execID string, masterTTY *types.MasterTTY) { 15 | conn, err := server.upgrader.Upgrade(c.Writer, c.Request, nil) 16 | if err != nil { 17 | log.Errorf("upgrade ws error: %s", err) 18 | return 19 | } 20 | defer conn.Close() 21 | // note: must read the init message 22 | // although it's useless in this situation 23 | server.readInitMessage(conn) 24 | 25 | ctx := c.Request.Context() 26 | containerID, ok := server.getContainerID(execID) 27 | if !ok { 28 | log.Error("share terminal error, exec not found") 29 | conn.WriteMessage(websocket.CloseMessage, 30 | []byte("exec container not found, exit")) 31 | return 32 | } 33 | 34 | cInfo := server.containerCli.GetInfo(ctx, containerID) 35 | var titleExtra = "[READONLY]" 36 | if server.options.Collaborate { 37 | titleExtra = "[SLAVE]" 38 | } 39 | titleBuf, err := server.makeTitleBuff(cInfo, titleExtra) 40 | if err != nil { 41 | e := fmt.Sprintf("failed to fill window title template: %s", err) 42 | conn.WriteMessage(websocket.CloseMessage, []byte(e)) 43 | log.Error(e) 44 | return 45 | } 46 | 47 | master := masterTTY.Fork(ctx, true) 48 | defer master.Close() 49 | 50 | ttyOptions := []webtty.Option{webtty.WithWindowTitle(titleBuf)} 51 | if server.options.Collaborate { 52 | ttyOptions = append(ttyOptions, webtty.WithPermitWrite()) 53 | } 54 | 55 | tty, err := webtty.New( 56 | &wsWrapper{conn}, 57 | newSlave(master), 58 | ttyOptions..., 59 | ) 60 | if err != nil { 61 | e := fmt.Sprintf("failed to create webtty: %s", err) 62 | conn.WriteMessage(websocket.CloseMessage, []byte(e)) 63 | log.Error(e) 64 | return 65 | } 66 | 67 | err = tty.Run(ctx) 68 | if err != nil && err != webtty.ErrMasterClosed { 69 | e := fmt.Sprintf("failed to run webtty: %s", err) 70 | log.Error(e) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /route/slave_wrapper.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/wrfly/container-web-tty/third-part/gotty/webtty" 7 | ) 8 | 9 | type slaveWrapper struct { 10 | master io.ReadWriteCloser 11 | } 12 | 13 | func (sw *slaveWrapper) WindowTitleVariables() map[string]interface{} { 14 | return nil 15 | } 16 | 17 | func (sw *slaveWrapper) ResizeTerminal(columns int, rows int) error { 18 | return nil 19 | } 20 | 21 | func (sw *slaveWrapper) Write(p []byte) (n int, err error) { 22 | return sw.master.Write(p) 23 | } 24 | 25 | func (sw *slaveWrapper) Read(p []byte) (n int, err error) { 26 | return sw.master.Read(p) 27 | } 28 | 29 | func newSlave(master io.ReadWriteCloser) webtty.Slave { 30 | return &slaveWrapper{master: master} 31 | } 32 | -------------------------------------------------------------------------------- /route/types.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // RunOptions holds a set of configurations for Server.Run(). 8 | type RunOptions struct { 9 | gracefulCtx context.Context 10 | } 11 | 12 | // RunOption is an option of Server.Run(). 13 | type RunOption func(*RunOptions) 14 | 15 | // WithGracefullContext accepts a context to shutdown a Server 16 | // with care for existing client connections. 17 | func WithGracefullContext(ctx context.Context) RunOption { 18 | return func(options *RunOptions) { 19 | options.gracefulCtx = ctx 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /route/ws.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func (server *Server) handleWSIndex(c *gin.Context) { 13 | var ( 14 | containerID = c.Param("cid") 15 | execID = c.Param("eid") 16 | _foundExecID bool 17 | ) 18 | if containerID == "" { 19 | containerID, _foundExecID = server.getContainerID(execID) 20 | if !_foundExecID { 21 | c.String(http.StatusBadRequest, fmt.Sprintf("exec id %s not found", execID)) 22 | return 23 | } 24 | } 25 | cInfo := server.containerCli.GetInfo(c.Request.Context(), containerID) 26 | titleVars := server.titleVariables( 27 | []string{"server"}, 28 | map[string]map[string]interface{}{ 29 | "server": { 30 | "containerName": cInfo.Name, 31 | }, 32 | }, 33 | ) 34 | 35 | titleBuf := new(bytes.Buffer) 36 | err := titleTemplate.Execute(titleBuf, titleVars) 37 | if err != nil { 38 | c.Error(err) 39 | } 40 | 41 | indexVars := map[string]interface{}{ 42 | "title": titleBuf.String(), 43 | "base": strings.TrimSuffix(server.options.Base, "/"), 44 | } 45 | 46 | indexBuf := new(bytes.Buffer) 47 | err = indexTemplate.Execute(indexBuf, indexVars) 48 | if err != nil { 49 | c.Error(err) 50 | } 51 | 52 | c.Writer.Write(indexBuf.Bytes()) 53 | } 54 | -------------------------------------------------------------------------------- /route/ws_wrapper.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "github.com/gorilla/websocket" 5 | ) 6 | 7 | type wsWrapper struct { 8 | *websocket.Conn 9 | } 10 | 11 | func (wsw *wsWrapper) Write(p []byte) (n int, err error) { 12 | writer, err := wsw.Conn.NextWriter(websocket.TextMessage) 13 | if err != nil { 14 | return 0, err 15 | } 16 | defer writer.Close() 17 | return writer.Write(p) 18 | } 19 | 20 | func (wsw *wsWrapper) Read(p []byte) (n int, err error) { 21 | for { 22 | msgType, reader, err := wsw.Conn.NextReader() 23 | if err != nil { 24 | return 0, err 25 | } 26 | 27 | if msgType != websocket.TextMessage { 28 | continue 29 | } 30 | return reader.Read(p) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/urfave/cli/v2" 8 | "github.com/wrfly/ecp" 9 | 10 | "github.com/wrfly/container-web-tty/config" 11 | "github.com/wrfly/container-web-tty/container" 12 | "github.com/wrfly/container-web-tty/proxy" 13 | "github.com/wrfly/container-web-tty/route" 14 | "github.com/wrfly/container-web-tty/util" 15 | ) 16 | 17 | func run(c *cli.Context, conf config.Config) { 18 | srvOptions := conf.Server 19 | 20 | if len(conf.Backend.GRPC.Servers) > 0 { 21 | srvOptions.ShowLocation = true 22 | } 23 | if err := ecp.Parse(&srvOptions); err != nil { 24 | logrus.Fatal(err) 25 | } 26 | 27 | if srvOptions.GrpcPort <= 0 && srvOptions.Port <= 0 { 28 | logrus.Fatal("bad config, no port listenning") 29 | } 30 | 31 | containerCli, err := container.NewCliBackend(conf.Backend) 32 | if err != nil { 33 | logrus.Fatalf("Create backend client error: %s", err) 34 | } 35 | defer containerCli.Close() 36 | 37 | ctx, cancel := context.WithCancel(context.Background()) 38 | gCtx, gCancel := context.WithCancel(ctx) 39 | errs := make(chan error, 2) 40 | 41 | // run HTTP server if port > 0 42 | if srvOptions.Port > 0 { 43 | go func() { 44 | srv, err := route.New(containerCli, srvOptions) 45 | if err != nil { 46 | logrus.Fatalf("Create server error: %s", err) 47 | } 48 | errs <- srv.Run(ctx, route.WithGracefullContext(gCtx)) 49 | }() 50 | } 51 | 52 | // run grpc server if grpc-port > 0 53 | if srvOptions.GrpcPort > 0 { 54 | go func() { 55 | grpcServer := proxy.New(conf.Backend.GRPC.Auth, 56 | srvOptions.GrpcPort, containerCli) 57 | errs <- grpcServer.Run(ctx, gCtx) 58 | }() 59 | } 60 | 61 | err = util.WaitSignals(errs, cancel, gCancel) 62 | if err != nil && err != context.Canceled { 63 | logrus.Fatalf("Server closed with error: %s", err) 64 | } 65 | logrus.Info("Server closed") 66 | } 67 | -------------------------------------------------------------------------------- /third-part/gotty/webtty/option.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // Option is an option for WebTTY. 10 | type Option func(*WebTTY) error 11 | 12 | // WithPermitWrite sets a WebTTY to accept input from slaves. 13 | func WithPermitWrite() Option { 14 | return func(wt *WebTTY) error { 15 | wt.permitWrite = true 16 | return nil 17 | } 18 | } 19 | 20 | // WithFixedColumns sets a fixed width to TTY master. 21 | func WithFixedColumns(columns int) Option { 22 | return func(wt *WebTTY) error { 23 | wt.columns = columns 24 | return nil 25 | } 26 | } 27 | 28 | // WithFixedRows sets a fixed height to TTY master. 29 | func WithFixedRows(rows int) Option { 30 | return func(wt *WebTTY) error { 31 | wt.rows = rows 32 | return nil 33 | } 34 | } 35 | 36 | // WithWindowTitle sets the default window title of the session 37 | func WithWindowTitle(windowTitle []byte) Option { 38 | return func(wt *WebTTY) error { 39 | wt.windowTitle = windowTitle 40 | return nil 41 | } 42 | } 43 | 44 | // WithReconnect enables reconnection on the master side. 45 | func WithReconnect(timeInSeconds int) Option { 46 | return func(wt *WebTTY) error { 47 | wt.reconnect = timeInSeconds 48 | return nil 49 | } 50 | } 51 | 52 | // WithMasterPreferences sets an optional configuration of master. 53 | func WithMasterPreferences(preferences interface{}) Option { 54 | return func(wt *WebTTY) error { 55 | prefs, err := json.Marshal(preferences) 56 | if err != nil { 57 | return errors.Wrapf(err, "failed to marshal preferences as JSON") 58 | } 59 | wt.masterPrefs = prefs 60 | return nil 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /third-part/gotty/webtty/types.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | var ( 10 | // ErrMasterClosed is returned when the slave connection is closed. 11 | ErrMasterClosed = errors.New("master closed") 12 | // ErrSlaveClosed indicates the function has exited by the slave 13 | ErrSlaveClosed = errors.New("slave closed") 14 | ) 15 | 16 | // Protocols defines the name of this protocol, 17 | // which is supposed to be used to the subprotocol of Websockt streams. 18 | var Protocols = []string{"webtty"} 19 | 20 | const ( 21 | // Unknown message type, maybe sent by a bug 22 | UnknownInput = '0' 23 | // User input typically from a keyboard 24 | Input = '1' 25 | // Ping to the server 26 | Ping = '2' 27 | // Notify that the browser size has been changed 28 | ResizeTerminal = '3' 29 | ) 30 | 31 | const ( 32 | // Unknown message type, maybe set by a bug 33 | UnknownOutput = '0' 34 | // Normal output to the terminal 35 | Output = '1' 36 | // Pong to the browser 37 | Pong = '2' 38 | // Set window title of the terminal 39 | SetWindowTitle = '3' 40 | // Set terminal preference 41 | SetPreferences = '4' 42 | // Make terminal to reconnect 43 | SetReconnect = '5' 44 | ) 45 | 46 | // Master represents a PTY master, usually it's a websocket connection. 47 | type Master io.ReadWriter 48 | 49 | // Slave represents a PTY slave, typically it's a local command. 50 | type Slave interface { 51 | io.ReadWriter 52 | 53 | // WindowTitleVariables returns any values that can be used to fill out 54 | // the title of a terminal. 55 | WindowTitleVariables() map[string]interface{} 56 | 57 | // ResizeTerminal sets a new size of the terminal. 58 | ResizeTerminal(columns int, rows int) error 59 | } 60 | -------------------------------------------------------------------------------- /third-part/gotty/webtty/webtty.go: -------------------------------------------------------------------------------- 1 | // Package webtty provides a protocl and an implementation to 2 | // controll terminals thorough networks. 3 | package webtty 4 | 5 | import ( 6 | "context" 7 | "encoding/base64" 8 | "encoding/json" 9 | "sync" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // WebTTY bridges a PTY slave and its PTY master. 15 | // To support text-based streams and side channel commands such as 16 | // terminal resizing, WebTTY uses an original protocol. 17 | type WebTTY struct { 18 | // PTY Master, which probably a connection to browser 19 | masterConn Master 20 | // PTY Slave 21 | slave Slave 22 | 23 | windowTitle []byte 24 | permitWrite bool 25 | columns int 26 | rows int 27 | reconnect int // in seconds 28 | masterPrefs []byte 29 | 30 | bufferSize int 31 | writeMutex sync.Mutex 32 | } 33 | 34 | // New creates a new instance of WebTTY. 35 | // masterConn is a connection to the PTY master, 36 | // typically it's a websocket connection to a client. 37 | // slave is a PTY slave such as a local command with a PTY. 38 | func New(masterConn Master, slave Slave, options ...Option) (*WebTTY, error) { 39 | wt := &WebTTY{ 40 | masterConn: masterConn, 41 | slave: slave, 42 | 43 | permitWrite: false, 44 | columns: 0, 45 | rows: 0, 46 | 47 | bufferSize: 1024, 48 | } 49 | 50 | for _, option := range options { 51 | option(wt) 52 | } 53 | 54 | return wt, nil 55 | } 56 | 57 | // Run starts the main process of the WebTTY. 58 | // This method blocks until the context is canceled. 59 | // Note that the master and slave are left intact even 60 | // after the context is canceled. Closing them is caller's 61 | // responsibility. 62 | // If the connection to one end gets closed, returns ErrSlaveClosed or ErrMasterClosed. 63 | func (wt *WebTTY) Run(ctx context.Context) error { 64 | err := wt.sendInitializeMessage() 65 | if err != nil { 66 | return errors.Wrapf(err, "failed to send initializing message") 67 | } 68 | 69 | errs := make(chan error, 2) 70 | 71 | go func() { 72 | errs <- func() error { 73 | buffer := make([]byte, wt.bufferSize) 74 | for { 75 | n, err := wt.slave.Read(buffer) 76 | if err != nil { 77 | return ErrSlaveClosed 78 | } 79 | 80 | err = wt.handleSlaveReadEvent(buffer[:n]) 81 | if err != nil { 82 | return err 83 | } 84 | } 85 | }() 86 | }() 87 | 88 | go func() { 89 | errs <- func() error { 90 | buffer := make([]byte, wt.bufferSize) 91 | for { 92 | n, err := wt.masterConn.Read(buffer) 93 | if err != nil { 94 | return ErrMasterClosed 95 | } 96 | 97 | err = wt.handleMasterReadEvent(buffer[:n]) 98 | if err != nil { 99 | return err 100 | } 101 | } 102 | }() 103 | }() 104 | 105 | select { 106 | case <-ctx.Done(): 107 | err = ctx.Err() 108 | case err = <-errs: 109 | } 110 | 111 | return err 112 | } 113 | 114 | func (wt *WebTTY) sendInitializeMessage() error { 115 | if len(wt.windowTitle) != 0 { 116 | err := wt.masterWrite(append([]byte{SetWindowTitle}, wt.windowTitle...)) 117 | if err != nil { 118 | return errors.Wrapf(err, "failed to send window title") 119 | } 120 | } 121 | 122 | if wt.reconnect > 0 { 123 | reconnect, _ := json.Marshal(wt.reconnect) 124 | err := wt.masterWrite(append([]byte{SetReconnect}, reconnect...)) 125 | if err != nil { 126 | return errors.Wrapf(err, "failed to set reconnect") 127 | } 128 | } 129 | 130 | if wt.masterPrefs != nil { 131 | err := wt.masterWrite(append([]byte{SetPreferences}, wt.masterPrefs...)) 132 | if err != nil { 133 | return errors.Wrapf(err, "failed to set preferences") 134 | } 135 | } 136 | 137 | return nil 138 | } 139 | 140 | func (wt *WebTTY) handleSlaveReadEvent(data []byte) error { 141 | safeMessage := base64.StdEncoding.EncodeToString(data) 142 | err := wt.masterWrite(append([]byte{Output}, []byte(safeMessage)...)) 143 | if err != nil { 144 | return errors.Wrapf(err, "failed to send message to master") 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func (wt *WebTTY) masterWrite(data []byte) error { 151 | wt.writeMutex.Lock() 152 | defer wt.writeMutex.Unlock() 153 | 154 | _, err := wt.masterConn.Write(data) 155 | if err != nil { 156 | return errors.Wrapf(err, "failed to write to master") 157 | } 158 | 159 | return nil 160 | } 161 | 162 | func (wt *WebTTY) handleMasterReadEvent(data []byte) error { 163 | if len(data) == 0 { 164 | return errors.New("unexpected zero length read from master") 165 | } 166 | 167 | switch data[0] { 168 | case Input: 169 | if !wt.permitWrite { 170 | return nil 171 | } 172 | 173 | if len(data) <= 1 { 174 | return nil 175 | } 176 | 177 | _, err := wt.slave.Write(data[1:]) 178 | if err != nil { 179 | return errors.Wrapf(err, "failed to write received data to slave") 180 | } 181 | 182 | case Ping: 183 | err := wt.masterWrite([]byte{Pong}) 184 | if err != nil { 185 | return errors.Wrapf(err, "failed to return Pong message to master") 186 | } 187 | 188 | case ResizeTerminal: 189 | if wt.columns != 0 && wt.rows != 0 { 190 | break 191 | } 192 | 193 | if len(data) <= 1 { 194 | return errors.New("received malformed remote command for terminal resize: empty payload") 195 | } 196 | 197 | var args argResizeTerminal 198 | err := json.Unmarshal(data[1:], &args) 199 | if err != nil { 200 | return errors.Wrapf(err, "received malformed data for terminal resize") 201 | } 202 | rows := wt.rows 203 | if rows == 0 { 204 | rows = int(args.Rows) 205 | } 206 | 207 | columns := wt.columns 208 | if columns == 0 { 209 | columns = int(args.Columns) 210 | } 211 | 212 | wt.slave.ResizeTerminal(columns, rows) 213 | default: 214 | return errors.Errorf("unknown message type `%c`", data[0]) 215 | } 216 | 217 | return nil 218 | } 219 | 220 | type argResizeTerminal struct { 221 | Columns float64 222 | Rows float64 223 | } 224 | -------------------------------------------------------------------------------- /third-part/gotty/webtty/webtty_test.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "io" 7 | "sync" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type pipePair struct { 14 | *io.PipeReader 15 | *io.PipeWriter 16 | } 17 | 18 | type ptyPair struct { 19 | *io.PipeReader 20 | *io.PipeWriter 21 | } 22 | 23 | // WindowTitleVariables returns any values that can be used to fill out 24 | // the title of a terminal. 25 | func (s *ptyPair) WindowTitleVariables() map[string]interface{} { return nil } 26 | 27 | // ResizeTerminal sets a new size of the terminal. 28 | func (s *ptyPair) ResizeTerminal(int, int) error { return nil } 29 | 30 | func TestWebTTY(t *testing.T) { 31 | socketReader, socketWriter := io.Pipe() 32 | ptyReader, ptyWriter := io.Pipe() 33 | 34 | socket := &ptyPair{socketReader, socketWriter} 35 | pty := &ptyPair{ptyReader, ptyWriter} 36 | 37 | ctx, cancel := context.WithCancel(context.Background()) 38 | 39 | webTTY, err := New(socket, pty, 40 | WithPermitWrite(), 41 | ) 42 | assert.Nil(t, err) 43 | 44 | var wg sync.WaitGroup 45 | wg.Add(1) 46 | go func() { 47 | defer wg.Done() 48 | err := webTTY.Run(ctx) 49 | if err == context.Canceled { 50 | return 51 | } 52 | assert.Nil(t, err) 53 | }() 54 | 55 | t.Run("write from socket", func(t *testing.T) { 56 | // 1 as Input 57 | message := []byte("1" + "foobar") 58 | 59 | start := make(chan bool) 60 | go func() { 61 | <-start 62 | n, err := socket.Write(message) 63 | assert.Nil(t, err) 64 | assert.Equal(t, n, len(message)) 65 | }() 66 | 67 | close(start) 68 | buf := make([]byte, 1024) 69 | n, err := pty.Read(buf) 70 | assert.Nil(t, err) 71 | assert.EqualValues(t, string(message[1:]), string(buf[:n])) 72 | }) 73 | 74 | t.Run("write from pty", func(t *testing.T) { 75 | message := []byte("hello") 76 | 77 | start := make(chan bool) 78 | go func() { 79 | <-start 80 | _, err := pty.Write(message) 81 | assert.Nil(t, err) 82 | }() 83 | 84 | close(start) 85 | buf := make([]byte, 1024) 86 | n, err := socket.Read(buf) 87 | assert.Nil(t, err) 88 | decoded, err := base64.StdEncoding.DecodeString(string(buf[1:n])) 89 | assert.Nil(t, err) 90 | assert.EqualValues(t, string(message), string(decoded)) 91 | }) 92 | 93 | cancel() 94 | wg.Wait() 95 | } 96 | -------------------------------------------------------------------------------- /types/containers.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "strings" 5 | "sync" 6 | ) 7 | 8 | type Containers struct { 9 | c map[string]Container 10 | cs []Container 11 | m sync.RWMutex 12 | once sync.Once 13 | } 14 | 15 | func (cs *Containers) List() []Container { 16 | cs.init() 17 | return cs.cs 18 | } 19 | 20 | func (cs *Containers) Len() int { 21 | cs.init() 22 | 23 | return len(cs.c) 24 | } 25 | 26 | func (cs *Containers) Set(containers []Container) { 27 | cs.init() 28 | 29 | mapContainers := make(map[string]Container, len(containers)*2) 30 | for _, c := range containers { 31 | mapContainers[c.ID] = c 32 | if len(c.ID) >= 12 { 33 | mapContainers[c.ID[:12]] = c 34 | } 35 | } 36 | cs.m.Lock() 37 | cs.c = mapContainers 38 | cs.cs = containers 39 | cs.m.Unlock() 40 | } 41 | 42 | func (cs *Containers) Append(c Container) { 43 | cs.init() 44 | if c.ID == "" { 45 | return 46 | } 47 | 48 | cs.m.Lock() 49 | cs.c[c.ID] = c 50 | if len(c.ID) >= 12 { 51 | cs.c[c.ID[:12]] = c 52 | } 53 | cs.cs = append(cs.cs, c) 54 | cs.m.Unlock() 55 | } 56 | 57 | func (cs *Containers) Find(cID string) Container { 58 | cs.init() 59 | 60 | cs.m.RLock() 61 | defer cs.m.RUnlock() 62 | 63 | if c, exist := cs.c[cID]; exist { 64 | return c 65 | } 66 | // didn't find this cid 67 | rC := Container{} 68 | l := 0 69 | for id, info := range cs.c { 70 | if strings.HasPrefix(id, cID) { 71 | l++ 72 | rC = info 73 | } 74 | } 75 | if l == 1 { 76 | return rC 77 | } 78 | 79 | return Container{} 80 | } 81 | 82 | func (cs *Containers) SetShell(cID, shell string) { 83 | cs.init() 84 | cs.m.Lock() 85 | defer cs.m.Unlock() 86 | 87 | if c, exist := cs.c[cID]; exist { 88 | c.Shell = shell 89 | cs.c[cID] = c 90 | } 91 | } 92 | 93 | func (cs *Containers) init() { 94 | cs.once.Do(func() { 95 | cs.c = make(map[string]Container, 100) 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /types/tty.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/wrfly/pubsub" 10 | 11 | "github.com/wrfly/container-web-tty/third-part/gotty/webtty" 12 | ) 13 | 14 | var globalPubSuber = pubsub.NewMemPubSuber() 15 | 16 | // TTY is webtty.Slave with some additional methods. 17 | type TTY interface { 18 | webtty.Slave 19 | Exit() error 20 | // ActiveChan is to notify that the connection is active 21 | ActiveChan() <-chan struct{} 22 | } 23 | 24 | type SlaveTTY struct { 25 | ps pubsub.PubSubChan 26 | tty TTY 27 | 28 | readOnly bool 29 | masterOutputs []byte 30 | mWriter *mutexWriter 31 | } 32 | 33 | func (s *SlaveTTY) Read(p []byte) (int, error) { 34 | if len(s.masterOutputs) != 0 { 35 | copy(p[:len(s.masterOutputs)], s.masterOutputs) 36 | s.masterOutputs = nil 37 | return len(p), nil 38 | } 39 | 40 | bs := <-s.ps.Read() 41 | // logrus.Debugf("slave tty read: %s", bs) 42 | copy(p[:len(bs)], bs) 43 | return len(bs), nil 44 | } 45 | 46 | func (s *SlaveTTY) Write(p []byte) (int, error) { 47 | logrus.Debugf("browser write[slave]: %s", p) 48 | if s.readOnly { 49 | return len(p), nil 50 | } 51 | 52 | s.mWriter.mutex.Lock() 53 | defer s.mWriter.mutex.Unlock() 54 | if !s.mWriter.SlaveCanWrite(s) { 55 | return 0, nil // slave is writing, Slave cannot write 56 | } 57 | s.mWriter.lastSlaveWriteTime = time.Now() 58 | s.mWriter.lastWroteSlave = s 59 | 60 | return s.tty.Write(p) 61 | } 62 | 63 | func (s *SlaveTTY) Close() error { 64 | logrus.Debugf("close slave tty") 65 | return nil 66 | } 67 | 68 | type MasterTTY struct { 69 | TTY 70 | id string 71 | pubC pubsub.PubChan 72 | outputs []byte // previous outputs 73 | 74 | mWriter *mutexWriter 75 | } 76 | 77 | func (m *MasterTTY) Read(p []byte) (n int, err error) { 78 | n, err = m.TTY.Read(p) // read from tty 79 | // logrus.Debugf("read from container: %s", p[:n]) 80 | 81 | // publish to all, ignore the error 82 | _ = m.pubC.Write(p[:n]) 83 | 84 | return 85 | } 86 | 87 | func (m *MasterTTY) Write(p []byte) (n int, err error) { 88 | logrus.Debugf("browser write[master]: %s", p) 89 | 90 | m.mWriter.mutex.Lock() 91 | defer m.mWriter.mutex.Unlock() 92 | if !m.mWriter.MasterCanWrite() { 93 | return 0, nil // slave is writing, master cannot write 94 | } 95 | m.mWriter.lastMasterWriteTime = time.Now() 96 | 97 | return m.TTY.Write(p) // write to container 98 | } 99 | 100 | func (m *MasterTTY) Close() error { 101 | logrus.Debugf("close master/fork-master tty: %s", m.id) 102 | return nil 103 | } 104 | 105 | func (m *MasterTTY) Fork(ctx context.Context, collaborate bool) *SlaveTTY { 106 | pubsub, err := globalPubSuber.PubSub(ctx, m.id) 107 | if err != nil { 108 | panic(err) // shouldn't happen 109 | } 110 | outputs := make([]byte, len(m.outputs)) 111 | copy(outputs, m.outputs) 112 | return &SlaveTTY{ 113 | tty: m.TTY, 114 | ps: pubsub, 115 | // options 116 | readOnly: !collaborate, 117 | // previous outputs from master 118 | masterOutputs: outputs, 119 | // mutex writer 120 | mWriter: m.mWriter, 121 | } 122 | } 123 | 124 | func NewMasterTTY(ctx context.Context, t TTY, execID string) (*MasterTTY, error) { 125 | pubsub, err := globalPubSuber.PubSub(ctx, execID) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | master := &MasterTTY{ 131 | TTY: t, 132 | id: execID, 133 | pubC: pubsub, 134 | outputs: make([]byte, 1e3), 135 | 136 | mWriter: &mutexWriter{}, 137 | } 138 | 139 | go func() { 140 | for output := range pubsub.Read() { 141 | master.outputs = append(master.outputs, output...) 142 | // master.outputs = append(master.outputs, '\n') 143 | if len(master.outputs) > 1e3 { 144 | master.outputs = master.outputs[len(master.outputs)-1e3:] 145 | } 146 | } 147 | }() 148 | 149 | return master, nil 150 | } 151 | 152 | const _waiteWaitDuration = time.Second 153 | 154 | type mutexWriter struct { 155 | lastMasterWriteTime time.Time 156 | lastSlaveWriteTime time.Time 157 | lastWroteSlave *SlaveTTY 158 | 159 | mutex sync.Mutex 160 | } 161 | 162 | func (w *mutexWriter) MasterCanWrite() bool { 163 | return time.Since(w.lastSlaveWriteTime) > _waiteWaitDuration 164 | } 165 | 166 | func (w *mutexWriter) SlaveCanWrite(tty *SlaveTTY) bool { 167 | if time.Since(w.lastMasterWriteTime) < _waiteWaitDuration { 168 | return false 169 | } 170 | 171 | if w.lastWroteSlave == nil { 172 | return true 173 | } 174 | 175 | if w.lastWroteSlave == tty { 176 | return true 177 | } 178 | 179 | return time.Since(w.lastSlaveWriteTime) > _waiteWaitDuration 180 | } 181 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Container instance 4 | type Container struct { 5 | // common 6 | ID, Name string 7 | Image, Command string 8 | State, Status string // "running" "Up 13 minutes" 9 | IPs []string 10 | Shell string 11 | 12 | // k8s 13 | PodName, ContainerName string 14 | Namespace, RunningNode string 15 | 16 | // remote location server address 17 | // use this to locate the container 18 | // in the proxy mode 19 | LocServer string 20 | 21 | // exec commands in arguments 22 | // permit user to execute any command 23 | // in that container 24 | Exec ExecOptions 25 | } 26 | 27 | // ContainerActionMessage tells the web browser the action's status 28 | type ContainerActionMessage struct { 29 | Error string `json:"err"` 30 | Code int `json:"code"` 31 | Message string `json:"msg"` 32 | } 33 | 34 | type InitMessage struct { 35 | Arguments string `json:"Arguments,omitempty"` 36 | AuthToken string `json:"AuthToken,omitempty"` 37 | } 38 | 39 | type LogOptions struct { 40 | ID string 41 | Follow bool 42 | Tail string 43 | } 44 | 45 | type ContainerAct int 46 | 47 | const ( 48 | EXEC ContainerAct = iota 49 | LOGS 50 | ) 51 | 52 | type ExecOptions struct { 53 | User string 54 | Env string 55 | Cmd string 56 | // alias as `p` 57 | Privileged bool 58 | } 59 | -------------------------------------------------------------------------------- /util/id.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/base64" 6 | "math/rand" 7 | "time" 8 | ) 9 | 10 | const _min = 15 11 | 12 | // ID returns a base64 encoded id 13 | func ID(salt string) string { 14 | if len(salt) < _min { 15 | salt = string(md5.New().Sum([]byte(salt))) 16 | } 17 | 18 | b64 := make([]byte, base64.StdEncoding.EncodedLen(_min)) 19 | base64.StdEncoding.Encode(b64, []byte(salt[:_min])) 20 | rand.New(rand.NewSource(time.Now().UnixNano())). 21 | Shuffle(len(b64), func(i, j int) { 22 | x := b64[i] 23 | b64[i] = b64[j] 24 | b64[j] = x 25 | }) 26 | 27 | return string(b64) 28 | } 29 | -------------------------------------------------------------------------------- /util/rwc.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "io" 4 | 5 | type rwc struct { 6 | r io.ReadCloser 7 | } 8 | 9 | func (r *rwc) Read(bs []byte) (int, error) { 10 | return r.r.Read(bs) 11 | } 12 | 13 | func (r *rwc) Write(bs []byte) (int, error) { 14 | return len(bs), nil 15 | } 16 | 17 | func (r *rwc) Close() error { 18 | return r.r.Close() 19 | } 20 | 21 | func NopRWCloser(r io.ReadCloser) io.ReadWriteCloser { 22 | return &rwc{r} 23 | } 24 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | "syscall" 12 | 13 | pb "github.com/wrfly/container-web-tty/proxy/pb" 14 | "github.com/wrfly/container-web-tty/types" 15 | ) 16 | 17 | // ConvertTpContainer *pb.Container -> types.Container 18 | func ConvertPbContainer(c *pb.Container) types.Container { 19 | return types.Container{ 20 | ID: c.Id, 21 | Name: c.Name, 22 | Image: c.Image, 23 | Command: c.Command, 24 | State: c.State, 25 | Status: c.Status, 26 | IPs: c.Ips, 27 | Shell: c.Shell, 28 | PodName: c.PodName, 29 | ContainerName: c.ContainerName, 30 | Namespace: c.Namespace, 31 | RunningNode: c.RunningNode, 32 | LocServer: c.LocServer, 33 | Exec: types.ExecOptions{ 34 | Cmd: c.ExecCmd, 35 | Env: c.ExecEnv, 36 | User: c.ExecUser, 37 | }, 38 | } 39 | } 40 | 41 | // ConvertTpContainer types.Container -> *pb.Container 42 | func ConvertTpContainer(c types.Container) *pb.Container { 43 | return &pb.Container{ 44 | Id: c.ID, 45 | Name: c.Name, 46 | Image: c.Image, 47 | Command: c.Command, 48 | State: c.State, 49 | Status: c.Status, 50 | Ips: c.IPs, 51 | Shell: c.Shell, 52 | PodName: c.PodName, 53 | ContainerName: c.ContainerName, 54 | Namespace: c.Namespace, 55 | RunningNode: c.RunningNode, 56 | LocServer: c.LocServer, 57 | ExecCmd: c.Exec.Cmd, 58 | ExecEnv: c.Exec.Env, 59 | ExecUser: c.Exec.User, 60 | } 61 | } 62 | 63 | func HomeDIR() string { 64 | if h := os.Getenv("HOME"); h != "" { 65 | return h 66 | } 67 | return os.Getenv("USERPROFILE") // windows 68 | } 69 | 70 | func DockerCliPath() string { 71 | if runtime.GOOS == `darwin` { 72 | return "/usr/local/bin/docker" 73 | } 74 | return "/usr/bin/docker" 75 | } 76 | 77 | func KubeConfigPath() string { 78 | home := HomeDIR() 79 | if home == "" { 80 | return "" 81 | } 82 | return filepath.Join(home, ".kube", "config") 83 | } 84 | 85 | func EnvVars(e string) []string { 86 | e = strings.ToUpper(e) 87 | return []string{"WEB_TTY_" + strings.Replace(e, "-", "_", -1)} 88 | } 89 | 90 | func WaitSignals(errs chan error, cancel context.CancelFunc, gracefullCancel context.CancelFunc) error { 91 | sigChan := make(chan os.Signal, 1) 92 | signal.Notify( 93 | sigChan, 94 | syscall.SIGINT, 95 | syscall.SIGTERM, 96 | ) 97 | 98 | select { 99 | case err := <-errs: 100 | return err 101 | 102 | case s := <-sigChan: 103 | switch s { 104 | case syscall.SIGINT: 105 | gracefullCancel() 106 | select { 107 | case err := <-errs: 108 | return err 109 | case <-sigChan: 110 | fmt.Println("Force closing...") 111 | cancel() 112 | return nil 113 | } 114 | default: 115 | cancel() 116 | return nil 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /util/util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestID(t *testing.T) { 9 | fmt.Println(ID("hello-world-1234-qwer")) 10 | fmt.Println(ID("11")) 11 | } 12 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | var ( 8 | Version string 9 | CommitID string 10 | BuildAt string 11 | ) 12 | 13 | var author = []*cli.Author{ 14 | &cli.Author{ 15 | Name: "wrfly", 16 | Email: "mr.wrfly@gmail.com", 17 | }, 18 | } 19 | --------------------------------------------------------------------------------