├── .all-contributorsrc ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── main.yaml │ ├── pre-release.yaml │ ├── quick-build.yaml │ └── release.yml ├── .gitignore ├── .gotty ├── .mailmap ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── NEWS.md ├── README.md ├── README.md.sed ├── backend ├── doc.go └── localcommand │ ├── doc.go │ ├── factory.go │ ├── local_command.go │ ├── local_command_test.go │ └── options.go ├── bindata ├── bindata.go ├── bindata_dev.go └── static │ ├── css │ ├── index.css │ ├── xterm.css │ └── xterm_customize.css │ ├── favicon.ico │ ├── icon.svg │ ├── icon_192.png │ ├── index.html │ ├── js │ ├── gotty.js │ ├── gotty.js.LICENSE.txt │ ├── gotty.js.map │ └── gotty.licenses.txt │ └── manifest.json ├── favicon.ai ├── go.mod ├── go.sum ├── js ├── package-lock.json ├── package.json ├── src │ ├── MyModal.tsx │ ├── bootstrap.scss │ ├── main.ts │ ├── websocket.ts │ ├── webtty.tsx │ ├── xterm.tsx │ └── zmodem.tsx ├── tsconfig.json └── webpack.config.js ├── main.go ├── pkg ├── homedir │ └── expand.go └── randomstring │ └── generate.go ├── release-checklist.md ├── resources ├── favicon.ico ├── icon.svg ├── icon_192.png ├── index.css ├── index.html ├── manifest.json └── xterm_customize.css ├── screenshot.gif ├── server ├── handler_atomic.go ├── handlers.go ├── init_message.go ├── list_address.go ├── log_response_writer.go ├── middleware.go ├── options.go ├── run_option.go ├── server.go ├── slave.go └── ws_wrapper.go ├── utils ├── default.go └── flags.go ├── version.go └── webtty ├── codecs.go ├── doc.go ├── errors.go ├── master.go ├── message_types.go ├── option.go ├── slave.go ├── webtty.go └── webtty_test.go /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "gotty", 3 | "projectOwner": "sorenisanerd", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "yudai", 15 | "name": "Iwasaki Yudai", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/33192?v=4", 17 | "profile": "https://yudai.arielworks.com/", 18 | "contributions": [ 19 | "code" 20 | ] 21 | }, 22 | { 23 | "login": "sorenisanerd", 24 | "name": "Soren L. Hansen", 25 | "avatar_url": "https://avatars.githubusercontent.com/u/160090?v=4", 26 | "profile": "http://linux2go.dk/", 27 | "contributions": [ 28 | "bug" 29 | ] 30 | }, 31 | { 32 | "login": "uovobw", 33 | "name": "Andrea Lusuardi", 34 | "avatar_url": "https://avatars.githubusercontent.com/u/1194751?v=4", 35 | "profile": "https://github.com/uovobw", 36 | "contributions": [ 37 | "code" 38 | ] 39 | }, 40 | { 41 | "login": "moul", 42 | "name": "Manfred Touron", 43 | "avatar_url": "https://avatars.githubusercontent.com/u/94029?v=4", 44 | "profile": "https://github.com/moul", 45 | "contributions": [ 46 | "code" 47 | ] 48 | }, 49 | { 50 | "login": "svanellewee", 51 | "name": "Stephan", 52 | "avatar_url": "https://avatars.githubusercontent.com/u/1567439?v=4", 53 | "profile": "https://github.com/svanellewee", 54 | "contributions": [ 55 | "code" 56 | ] 57 | }, 58 | { 59 | "login": "QuentinPerez", 60 | "name": "Quentin Perez", 61 | "avatar_url": "https://avatars.githubusercontent.com/u/3081204?v=4", 62 | "profile": "https://fr.linkedin.com/in/quentinperez", 63 | "contributions": [ 64 | "code" 65 | ] 66 | }, 67 | { 68 | "login": "jizhilong", 69 | "name": "jzl", 70 | "avatar_url": "https://avatars.githubusercontent.com/u/816618?v=4", 71 | "profile": "https://github.com/jizhilong", 72 | "contributions": [ 73 | "code" 74 | ] 75 | }, 76 | { 77 | "login": "fazalmajid", 78 | "name": "Fazal Majid", 79 | "avatar_url": "https://avatars.githubusercontent.com/u/331198?v=4", 80 | "profile": "https://majid.info/", 81 | "contributions": [ 82 | "code" 83 | ] 84 | }, 85 | { 86 | "login": "Immortalin", 87 | "name": "Immortalin", 88 | "avatar_url": "https://avatars.githubusercontent.com/u/7126128?v=4", 89 | "profile": "https://narrationbox.com/", 90 | "contributions": [ 91 | "code" 92 | ] 93 | }, 94 | { 95 | "login": "freakhill", 96 | "name": "freakhill", 97 | "avatar_url": "https://avatars.githubusercontent.com/u/916582?v=4", 98 | "profile": "https://github.com/freakhill", 99 | "contributions": [ 100 | "code" 101 | ] 102 | }, 103 | { 104 | "login": "0xflotus", 105 | "name": "0xflotus", 106 | "avatar_url": "https://avatars.githubusercontent.com/u/26602940?v=4", 107 | "profile": "https://github.com/0xflotus", 108 | "contributions": [ 109 | "code" 110 | ] 111 | }, 112 | { 113 | "login": "skeltoac", 114 | "name": "Andy Skelton", 115 | "avatar_url": "https://avatars.githubusercontent.com/u/52292?v=4", 116 | "profile": "https://andy.blog/", 117 | "contributions": [ 118 | "code" 119 | ] 120 | }, 121 | { 122 | "login": "artdevjs", 123 | "name": "Artem Medvedev", 124 | "avatar_url": "https://avatars.githubusercontent.com/u/7567983?v=4", 125 | "profile": "https://twitter.com/artdevjs", 126 | "contributions": [ 127 | "code" 128 | ] 129 | }, 130 | { 131 | "login": "blakejennings", 132 | "name": "Blake Jennings", 133 | "avatar_url": "https://avatars.githubusercontent.com/u/1976331?v=4", 134 | "profile": "https://github.com/blakejennings", 135 | "contributions": [ 136 | "code" 137 | ] 138 | }, 139 | { 140 | "login": "jensenbox", 141 | "name": "Christian Jensen", 142 | "avatar_url": "https://avatars.githubusercontent.com/u/189265?v=4", 143 | "profile": "https://github.com/jensenbox", 144 | "contributions": [ 145 | "code" 146 | ] 147 | }, 148 | { 149 | "login": "TechWilk", 150 | "name": "Christopher Wilkinson", 151 | "avatar_url": "https://avatars.githubusercontent.com/u/9367803?v=4", 152 | "profile": "https://wilk.tech/", 153 | "contributions": [ 154 | "code" 155 | ] 156 | }, 157 | { 158 | "login": "RealCyGuy", 159 | "name": "Cyrus", 160 | "avatar_url": "https://avatars.githubusercontent.com/u/54488650?v=4", 161 | "profile": "https://github.com/RealCyGuy", 162 | "contributions": [ 163 | "code" 164 | ] 165 | }, 166 | { 167 | "login": "dehorsley", 168 | "name": "David Horsley", 169 | "avatar_url": "https://avatars.githubusercontent.com/u/3401668?v=4", 170 | "profile": "https://github.com/dehorsley", 171 | "contributions": [ 172 | "code" 173 | ] 174 | }, 175 | { 176 | "login": "Jason-Cooke", 177 | "name": "Jason Cooke", 178 | "avatar_url": "https://avatars.githubusercontent.com/u/5185660?v=4", 179 | "profile": "https://jasoncooke.dev/", 180 | "contributions": [ 181 | "code" 182 | ] 183 | }, 184 | { 185 | "login": "DenKoren", 186 | "name": "Denis Korenevskiy", 187 | "avatar_url": "https://avatars.githubusercontent.com/u/3419381?v=4", 188 | "profile": "https://github.com/DenKoren", 189 | "contributions": [ 190 | "code" 191 | ] 192 | }, 193 | { 194 | "login": "stucchimax", 195 | "name": "Massimiliano Stucchi", 196 | "avatar_url": "https://avatars.githubusercontent.com/u/1331438?v=4", 197 | "profile": "https://www.stucchi.ch/", 198 | "contributions": [ 199 | "code" 200 | ] 201 | }, 202 | { 203 | "login": "Felixoid", 204 | "name": "Mikhail f. Shiryaev", 205 | "avatar_url": "https://avatars.githubusercontent.com/u/3025537?v=4", 206 | "profile": "https://www.linkedin.com/in/felixoid/", 207 | "contributions": [ 208 | "code" 209 | ] 210 | }, 211 | { 212 | "login": "guywithnose", 213 | "name": "Robert Bittle", 214 | "avatar_url": "https://avatars.githubusercontent.com/u/1059169?v=4", 215 | "profile": "https://github.com/guywithnose", 216 | "contributions": [ 217 | "code" 218 | ] 219 | }, 220 | { 221 | "login": "sehaas", 222 | "name": "sebastian haas", 223 | "avatar_url": "https://avatars.githubusercontent.com/u/283482?v=4", 224 | "profile": "https://deebas.com/", 225 | "contributions": [ 226 | "code" 227 | ] 228 | }, 229 | { 230 | "login": "shoz", 231 | "name": "shoji", 232 | "avatar_url": "https://avatars.githubusercontent.com/u/225194?v=4", 233 | "profile": "https://github.com/shoz", 234 | "contributions": [ 235 | "code" 236 | ] 237 | }, 238 | { 239 | "login": "tsl0922", 240 | "name": "Shuanglei Tao", 241 | "avatar_url": "https://avatars.githubusercontent.com/u/1680515?v=4", 242 | "profile": "https://github.com/tsl0922", 243 | "contributions": [ 244 | "code" 245 | ] 246 | }, 247 | { 248 | "login": "gitter-badger", 249 | "name": "The Gitter Badger", 250 | "avatar_url": "https://avatars.githubusercontent.com/u/8518239?v=4", 251 | "profile": "https://gitter.im/", 252 | "contributions": [ 253 | "code" 254 | ] 255 | }, 256 | { 257 | "login": "xinsnake", 258 | "name": "Jacob Zhou", 259 | "avatar_url": "https://avatars.githubusercontent.com/u/1287677?v=4", 260 | "profile": "https://github.com/xinsnake", 261 | "contributions": [ 262 | "code" 263 | ] 264 | }, 265 | { 266 | "login": "zyfdegh", 267 | "name": "zyfdegh", 268 | "avatar_url": "https://avatars.githubusercontent.com/u/7880217?v=4", 269 | "profile": "https://github.com/zyfdegh", 270 | "contributions": [ 271 | "code" 272 | ] 273 | }, 274 | { 275 | "login": "fredster33", 276 | "name": "fredster33", 277 | "avatar_url": "https://avatars.githubusercontent.com/u/64927044?v=4", 278 | "profile": "https://github.com/fredster33", 279 | "contributions": [ 280 | "code" 281 | ] 282 | }, 283 | { 284 | "login": "mattn", 285 | "name": "mattn", 286 | "avatar_url": "https://avatars.githubusercontent.com/u/10111?v=4", 287 | "profile": "https://mattn.kaoriya.net/", 288 | "contributions": [ 289 | "code" 290 | ] 291 | }, 292 | { 293 | "login": "shingt", 294 | "name": "Shinichi Goto", 295 | "avatar_url": "https://avatars.githubusercontent.com/u/1391330?v=4", 296 | "profile": "https://www.shingt.com/", 297 | "contributions": [ 298 | "code" 299 | ] 300 | }, 301 | { 302 | "login": "ygit", 303 | "name": "ygit", 304 | "avatar_url": "https://avatars.githubusercontent.com/u/8512357?v=4", 305 | "profile": "https://twitter.com/_yogeshsingh", 306 | "contributions": [ 307 | "code" 308 | ] 309 | }, 310 | { 311 | "login": "nephaste", 312 | "name": "Stéphane", 313 | "avatar_url": "https://avatars.githubusercontent.com/u/3392684?v=4", 314 | "profile": "http://forum.cachem.fr/viewforum.php?f=21", 315 | "contributions": [ 316 | "bug" 317 | ] 318 | }, 319 | { 320 | "login": "prusnak", 321 | "name": "Pavol Rusnak", 322 | "avatar_url": "https://avatars.githubusercontent.com/u/42201?v=4", 323 | "profile": "https://rusnak.io/", 324 | "contributions": [ 325 | "bug" 326 | ] 327 | }, 328 | { 329 | "login": "devanlai", 330 | "name": "Devan Lai", 331 | "avatar_url": "https://avatars.githubusercontent.com/u/1348448?v=4", 332 | "profile": "https://github.com/devanlai", 333 | "contributions": [ 334 | "code" 335 | ] 336 | }, 337 | { 338 | "login": "jkandasa", 339 | "name": "Jeeva Kandasamy", 340 | "avatar_url": "https://avatars.githubusercontent.com/u/1004403?v=4", 341 | "profile": "https://github.com/jkandasa", 342 | "contributions": [ 343 | "code" 344 | ] 345 | }, 346 | { 347 | "login": "hardliner66", 348 | "name": "Steve Biedermann", 349 | "avatar_url": "https://avatars.githubusercontent.com/u/2937272?v=4", 350 | "profile": "https://twitch.tv/iamhardliner", 351 | "contributions": [ 352 | "code" 353 | ] 354 | }, 355 | { 356 | "login": "xgdgsc", 357 | "name": "xgdgsc", 358 | "avatar_url": "https://avatars.githubusercontent.com/u/1189869?v=4", 359 | "profile": "https://github.com/xgdgsc", 360 | "contributions": [ 361 | "bug" 362 | ] 363 | }, 364 | { 365 | "login": "flechaig", 366 | "name": "flechaig", 367 | "avatar_url": "https://avatars.githubusercontent.com/u/10887132?v=4", 368 | "profile": "https://github.com/flechaig", 369 | "contributions": [ 370 | "bug" 371 | ] 372 | }, 373 | { 374 | "login": "Fan-SJ", 375 | "name": "Fan-SJ", 376 | "avatar_url": "https://avatars.githubusercontent.com/u/49977708?v=4", 377 | "profile": "https://github.com/Fan-SJ", 378 | "contributions": [ 379 | "bug" 380 | ] 381 | }, 382 | { 383 | "login": "dmartin", 384 | "name": "Dustin Martin", 385 | "avatar_url": "https://avatars.githubusercontent.com/u/1657652?v=4", 386 | "profile": "https://github.com/dmartin", 387 | "contributions": [ 388 | "bug" 389 | ] 390 | }, 391 | { 392 | "login": "ahmetb", 393 | "name": "Ahmet Alp Balkan", 394 | "avatar_url": "https://avatars.githubusercontent.com/u/159209?v=4", 395 | "profile": "https://ahmet.dev/", 396 | "contributions": [ 397 | "bug" 398 | ] 399 | }, 400 | { 401 | "login": "CoconutMacaroon", 402 | "name": "CoconutMacaroon", 403 | "avatar_url": "https://avatars.githubusercontent.com/u/45187468?v=4", 404 | "profile": "https://github.com/CoconutMacaroon", 405 | "contributions": [ 406 | "bug" 407 | ] 408 | }, 409 | { 410 | "login": "DannyBen", 411 | "name": "Danny Ben Shitrit", 412 | "avatar_url": "https://avatars.githubusercontent.com/u/2405099?v=4", 413 | "profile": "https://github.dannyben.com/", 414 | "contributions": [ 415 | "bug" 416 | ] 417 | }, 418 | { 419 | "login": "George-NG", 420 | "name": "George-NG", 421 | "avatar_url": "https://avatars.githubusercontent.com/u/28577165?v=4", 422 | "profile": "https://github.com/George-NG", 423 | "contributions": [ 424 | "bug" 425 | ] 426 | }, 427 | { 428 | "login": "ghthor", 429 | "name": "Will Owens", 430 | "avatar_url": "https://avatars.githubusercontent.com/u/160298?v=4", 431 | "profile": "https://github.com/ghthor", 432 | "contributions": [ 433 | "bug" 434 | ] 435 | }, 436 | { 437 | "login": "jpillora", 438 | "name": "Jaime Pillora", 439 | "avatar_url": "https://avatars.githubusercontent.com/u/633843?v=4", 440 | "profile": "https://jpillora.com/", 441 | "contributions": [ 442 | "bug" 443 | ] 444 | }, 445 | { 446 | "login": "kaisawind", 447 | "name": "kaisawind", 448 | "avatar_url": "https://avatars.githubusercontent.com/u/4010613?v=4", 449 | "profile": "https://github.com/kaisawind", 450 | "contributions": [ 451 | "bug" 452 | ] 453 | }, 454 | { 455 | "login": "linyinli", 456 | "name": "linyinli", 457 | "avatar_url": "https://avatars.githubusercontent.com/u/42955482?v=4", 458 | "profile": "https://github.com/linyinli", 459 | "contributions": [ 460 | "bug" 461 | ] 462 | }, 463 | { 464 | "login": "LucaMarconato", 465 | "name": "LucaMarconato", 466 | "avatar_url": "https://avatars.githubusercontent.com/u/2664412?v=4", 467 | "profile": "https://github.com/LucaMarconato", 468 | "contributions": [ 469 | "bug" 470 | ] 471 | }, 472 | { 473 | "login": "masterkain", 474 | "name": "Kain", 475 | "avatar_url": "https://avatars.githubusercontent.com/u/12844?v=4", 476 | "profile": "https://audiobox.fm/", 477 | "contributions": [ 478 | "bug" 479 | ] 480 | }, 481 | { 482 | "login": "Nexuist", 483 | "name": "Andi Andreas", 484 | "avatar_url": "https://avatars.githubusercontent.com/u/1498061?v=4", 485 | "profile": "http://duro.me/", 486 | "contributions": [ 487 | "bug" 488 | ] 489 | }, 490 | { 491 | "login": "qigj", 492 | "name": "qigj", 493 | "avatar_url": "https://avatars.githubusercontent.com/u/56585735?v=4", 494 | "profile": "https://github.com/qigj", 495 | "contributions": [ 496 | "bug" 497 | ] 498 | }, 499 | { 500 | "login": "shuaiyy", 501 | "name": "shuaiyy", 502 | "avatar_url": "https://avatars.githubusercontent.com/u/19821321?v=4", 503 | "profile": "https://github.com/shuaiyy", 504 | "contributions": [ 505 | "bug" 506 | ] 507 | }, 508 | { 509 | "login": "v20z", 510 | "name": "v20z", 511 | "avatar_url": "https://avatars.githubusercontent.com/u/2884824?v=4", 512 | "profile": "https://github.com/v20z", 513 | "contributions": [ 514 | "bug" 515 | ] 516 | }, 517 | { 518 | "login": "Yann-Qiu", 519 | "name": "Yanfeng Qiu", 520 | "avatar_url": "https://avatars.githubusercontent.com/u/56961747?v=4", 521 | "profile": "https://github.com/Yann-Qiu", 522 | "contributions": [ 523 | "bug" 524 | ] 525 | } 526 | ], 527 | "contributorsPerLine": 7 528 | } 529 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # When file a bug report (see below for feature requests) 2 | 3 | Please answer these quesions for a bug report. Thanks! 4 | 5 | ### What version of GoTTY are you using (`gotty --version`)? 6 | 7 | 8 | ### What operating system and browser are you using? 9 | 10 | 11 | ### What did you do? 12 | 13 | If possible, please provide the command you ran. 14 | 15 | 16 | ### What did you expect to see? 17 | 18 | 19 | ### What did you see instead? 20 | 21 | If possible, please provide the output of the command and your browser's console output. 22 | 23 | 24 | 25 | # When file a new feature proposal 26 | 27 | Please provide an actual usecase that requires your new feature. 28 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: "Unit and Build Tests" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | 9 | jobs: 10 | bundle-up-to-date: 11 | runs-on: "ubuntu-latest" 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | - uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.19 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 16 23 | - run: "make clean" 24 | - run: "make assets" 25 | - name: "Make sure gotty.js bundle is up-to-date" 26 | run: "diffsize=$(git diff bindata/static/js/gotty.js | wc -l); test $diffsize == 0" 27 | 28 | 29 | cross-compile-test: 30 | runs-on: "ubuntu-latest" 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | with: 35 | fetch-depth: 0 36 | 37 | - name: Set up Go 38 | uses: actions/setup-go@v3 39 | with: 40 | go-version: 1.19 41 | 42 | - name: "Build & test" 43 | run: "make tools test cross_compile" 44 | 45 | - name: Upload artifacts 46 | uses: actions/upload-artifact@v3 47 | with: 48 | name: binaries 49 | path: builds/pkg/*/gotty -------------------------------------------------------------------------------- /.github/workflows/pre-release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "pre-release" 3 | 4 | on: [push] 5 | 6 | env: 7 | IMAGE_NAME: ${{ github.repository }} 8 | 9 | jobs: 10 | pre-release-docker: 11 | name: "Pre Release Docker" 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - 15 | name: Docker meta 16 | id: meta 17 | uses: docker/metadata-action@v4 18 | with: 19 | images: ${{ env.IMAGE_NAME }} 20 | tags: | 21 | type=raw,value=latest,enable={{is_default_branch}} 22 | type=sha,format=long 23 | type=sha 24 | type=semver,pattern=v{{major}}.{{minor}}.{{patch}} 25 | type=semver,pattern=v{{major}}.{{minor}} 26 | type=semver,pattern=v{{major}} 27 | type=ref,event=tag 28 | type=ref,event=branch 29 | 30 | - uses: docker/setup-qemu-action@v2 31 | - uses: docker/setup-buildx-action@v2 32 | - uses: docker/login-action@v2 33 | with: 34 | username: "${{ secrets.DOCKER_HUB_USER }}" 35 | password: "${{ secrets.DOCKER_HUB_TOKEN }}" 36 | - name: "Build and push docker image" 37 | uses: docker/build-push-action@v4 38 | with: 39 | platforms: linux/amd64,linux/arm/v7,linux/arm64 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /.github/workflows/quick-build.yaml: -------------------------------------------------------------------------------- 1 | name: "Quick build test" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | 9 | jobs: 10 | quick-build-test: 11 | runs-on: "ubuntu-latest" 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v3 20 | with: 21 | go-version: 1.19 22 | 23 | - name: "Build" 24 | run: "touch bindata/* bindata/*/* ; make" 25 | 26 | - name: Upload linux/amd64 artifact 27 | uses: actions/upload-artifact@v3 28 | with: 29 | name: gotty-linux-amd64 30 | path: gotty 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "tagged-release" 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | tagged-release: 11 | name: "Tagged Release" 12 | runs-on: "ubuntu-latest" 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.19 23 | 24 | - name: "Build & test" 25 | run: "make tools test release-artifacts" 26 | 27 | - name: Upload build artifacts 28 | uses: actions/upload-artifact@v3 29 | with: 30 | name: binaries 31 | path: builds/pkg/*/gotty 32 | 33 | - uses: "marvinpinto/action-automatic-releases@latest" 34 | with: 35 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 36 | prerelease: false 37 | draft: true 38 | files: | 39 | LICENSE 40 | builds/dist/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gotty 2 | builds 3 | js/dist 4 | js/node_modules/* 5 | -------------------------------------------------------------------------------- /.gotty: -------------------------------------------------------------------------------- 1 | // [string] Address to listen, all addresses will be used when empty 2 | // address = "" 3 | 4 | // [string] Port to listen 5 | // port = "8080" 6 | 7 | // [bool] Permit clients to write to the TTY 8 | // permit_write = false 9 | 10 | // [bool] Enable basic authentication 11 | // enable_basic_auth = false 12 | 13 | // [string] Default username and password of basic authentication (user:pass) 14 | // To enable basic authentication, set `true` to `enable_basic_auth` 15 | // credential = "user:pass" 16 | 17 | // [bool] Enable random URL generation 18 | // enable_random_url = false 19 | 20 | // [int] Default length of random strings appended to URL 21 | // To enable random URL generation, set `true` to `enable_random_url` 22 | // random_url_length = 8 23 | 24 | // [bool] Enable TLS/SSL 25 | // enable_tls = false 26 | 27 | // [string] Default TLS certificate file path 28 | // tls_crt_file = "~/.gotty.crt" 29 | 30 | // [string] Default TLS key file path 31 | // tls_key_file = "~/.gotty.key" 32 | 33 | // [bool] Enable client certificate authentication 34 | // enable_tls_client_auth = false 35 | 36 | // [string] Certificate file of CA for client certificates 37 | // tls_ca_crt_file = "~/.gotty.ca.crt" 38 | 39 | // [string] Custom index.html file 40 | // index_file = "" 41 | 42 | // [string] Title format of browser window 43 | // Available variables are: 44 | // Command Command string 45 | // Pid PID of the process for the client 46 | // Hostname Server hostname 47 | // RemoteAddr Client IP address 48 | // title_format = "GoTTY - {{ .Command }} ({{ .Hostname }})" 49 | 50 | // [bool] Enable client side reconnection when connection closed 51 | // enable_reconnect = false 52 | 53 | // [int] Interval time to try reconnection (seconds) 54 | // To enable reconnection, set `true` to `enable_reconnect` 55 | // reconnect_time = 10 56 | 57 | // [int] Timeout seconds for waiting a client (0 to disable) 58 | // timeout = 60 59 | 60 | // [int] Maximum connection to gotty, 0(default) means no limit. 61 | // max_connection = 0 62 | 63 | // [bool] Accept only one client and exit gotty once the client exits 64 | // once = false 65 | 66 | // [bool] Permit clients to send command line arguments in URL (e.g. http://example.com:8080/?arg=AAA&arg=BBB) 67 | // permit_arguments = false 68 | 69 | // [object] Client terminal (hterm) preferences 70 | // preferences { 71 | 72 | // [enum(null, "none", "ctrl-alt", "left-alt", "right-alt")] 73 | // Select an AltGr detection hack^Wheuristic. 74 | // null: Autodetect based on navigator.language: "en-us" => "none", else => "right-alt" 75 | // "none": Disable any AltGr related munging. 76 | // "ctrl-alt": Assume Ctrl+Alt means AltGr. 77 | // "left-alt": Assume left Alt means AltGr. 78 | // "right-alt": Assume right Alt means AltGr. 79 | // alt_gr_mode = null 80 | 81 | // [bool] If set, alt-backspace indeed is alt-backspace. 82 | // alt_backspace_is_meta_backspace = false 83 | 84 | // [bool] Set whether the alt key acts as a meta key or as a distinct alt key. 85 | // alt_is_meta = false 86 | 87 | // [enum("escape", "8-bit", "browser-key")] 88 | // Controls how the alt key is handled. 89 | // "escape"....... Send an ESC prefix. 90 | // "8-bit"........ Add 128 to the unshifted character as in xterm. 91 | // "browser-key".. Wait for the keypress event and see what the browser says. 92 | // (This won't work well on platforms where the browser performs a default action for some alt sequences.) 93 | // alt_sends_what = "escape" 94 | 95 | // [string] URL of the terminal bell sound. Empty string for no audible bell. 96 | // audible_bell_sound = "lib-resource:hterm/audio/bell" 97 | 98 | // [bool] If true, terminal bells in the background will create a Web Notification. http://www.w3.org/TR/notifications/ 99 | // Displaying notifications requires permission from the user. 100 | // When this option is set to true, hterm will attempt to ask the user for permission if necessary. 101 | // Note browsers may not show this permission request 102 | // if it did not originate from a user action. 103 | // desktop_notification_bell = false 104 | 105 | // [string] The background color for text with no other color attributes. 106 | // background_color = "rgb(16, 16, 16)" 107 | 108 | // [string] CSS value of the background image. Empty string for no image. 109 | // For example: 110 | // "url(https://goo.gl/anedTK) linear-gradient(top bottom, blue, red)" 111 | // background_image = "" 112 | 113 | // [string] CSS value of the background image size. Defaults to none. 114 | // background_size = "" 115 | 116 | // [string] CSS value of the background image position. 117 | // For example: 118 | // "10% 10% center" 119 | // background_position = "" 120 | 121 | // [bool] If true, the backspace should send BS ('\x08', aka ^H). Otherwise the backspace key should send '\x7f'. 122 | // backspace_sends_backspace = false 123 | 124 | // [map[string]map[string]string] 125 | // A nested map where each property is the character set code and the value is a map that is a sparse array itself. 126 | // In that sparse array, each property is the received character and the value is the displayed character. 127 | // For example: 128 | // {"0" = {"+" = "\u2192" 129 | // "," = "\u2190" 130 | // "-" = "\u2191" 131 | // "." = "\u2193" 132 | // "0" = "\u2588"}} 133 | // character_map_overrides = null 134 | 135 | // [bool] Whether or not to close the window when the command exits. 136 | // close_on_exit = true 137 | 138 | // [bool] Whether or not to blink the cursor by default. 139 | // cursor_blink = false 140 | 141 | // [2[int]] The cursor blink rate in milliseconds. 142 | // A two element array, the first of which is how long the cursor should be on, second is how long it should be off. 143 | // cursor_blink_cycle = [1000, 500] 144 | 145 | // [string] The color of the visible cursor. 146 | // cursor_color = "rgba(255, 0, 0, 0.5)" 147 | 148 | // [[]string] 149 | // Override colors in the default palette. 150 | // This can be specified as an array or an object. 151 | // Values can be specified as almost any css color value. 152 | // This includes #RGB, #RRGGBB, rgb(...), rgba(...), and any color names that are also part of the stock X11 rgb.txt file. 153 | // You can use 'null' to specify that the default value should be not be changed. 154 | // This is useful for skipping a small number of indicies when the value is specified as an array. 155 | // color_palette_overrides = null 156 | 157 | // [bool] Automatically copy mouse selection to the clipboard. 158 | // copy_on_select = true 159 | 160 | // [bool] Whether to use the default window copy behaviour 161 | // use_default_window_copy = false 162 | 163 | // [bool] Whether to clear the selection after copying. 164 | // clear_selection_after_copy = true 165 | 166 | // [bool] If true, Ctrl-Plus/Minus/Zero controls zoom. 167 | // If false, Ctrl-Shift-Plus/Minus/Zero controls zoom, Ctrl-Minus sends ^_, Ctrl-Plus/Zero do nothing. 168 | // ctrl_plus_minus_zero_zoom = true 169 | 170 | // [bool] Ctrl+C copies if true, send ^C to host if false. 171 | // Ctrl+Shift+C sends ^C to host if true, copies if false. 172 | // ctrl_c_copy = false 173 | 174 | // [bool] Ctrl+V pastes if true, send ^V to host if false. 175 | // Ctrl+Shift+V sends ^V to host if true, pastes if false. 176 | // ctrl_v_paste = false 177 | 178 | // [bool] Set whether East Asian Ambiguous characters have two column width. 179 | // east_asian_ambiguous_as_two_column = false 180 | 181 | // [bool] True to enable 8-bit control characters, false to ignore them. 182 | // We'll respect the two-byte versions of these control characters regardless of this setting. 183 | // enable_8_bit_control = false 184 | 185 | // [enum(null, true, false)] 186 | // True if we should use bold weight font for text with the bold/bright attribute. 187 | // False to use the normal weight font. 188 | // Null to autodetect. 189 | // enable_bold = null 190 | 191 | // [bool] True if we should use bright colors (8-15 on a 16 color palette) for any text with the bold attribute. 192 | // False otherwise. 193 | // enable_bold_as_bright = true 194 | 195 | // [bool] Show a message in the terminal when the host writes to the clipboard. 196 | // enable_clipboard_notice = true 197 | 198 | // [bool] Allow the host to write directly to the system clipboard. 199 | // enable_clipboard_write = true 200 | 201 | // [bool] Respect the host's attempt to change the cursor blink status using DEC Private Mode 12. 202 | // enable_dec12 = false 203 | 204 | // [map[string]string] The default environment variables, as an object. 205 | // environment = {"TERM" = "xterm-256color"} 206 | 207 | // [string] Default font family for the terminal text. 208 | // font_family = "'DejaVu Sans Mono', 'Everson Mono', FreeMono, 'Menlo', 'Terminal', monospace" 209 | 210 | // [int] The default font size in pixels. 211 | // font_size = 15 212 | 213 | // [string] CSS font-smoothing property. 214 | // font_smoothing = "antialiased" 215 | 216 | // [string] The foreground color for text with no other color attributes. 217 | // foreground_color = "rgb(240, 240, 240)" 218 | 219 | // [bool] If true, home/end will control the terminal scrollbar and shift home/end will send the VT keycodes. 220 | // If false then home/end sends VT codes and shift home/end scrolls. 221 | // home_keys_scroll = false 222 | 223 | // [map[string]string] 224 | // A map of key sequence to key actions. 225 | // Key sequences include zero or more modifier keys followed by a key code. 226 | // Key codes can be decimal or hexadecimal numbers, or a key identifier. 227 | // Key actions can be specified a string to send to the host, or an action identifier. 228 | // For a full list of key code and action identifiers, see https://goo.gl/8AoD09. 229 | // Sample keybindings: 230 | // {"Ctrl-Alt-K" = "clearScrollback" 231 | // "Ctrl-Shift-L"= "PASS" 232 | // "Ctrl-H" = "'HELLO\n'"} 233 | // keybindings = null 234 | 235 | // [int] Max length of a DCS, OSC, PM, or APS sequence before we give up and ignore the code. 236 | // max_string_sequence = 100000 237 | 238 | // [bool] If true, convert media keys to their Fkey equivalent. 239 | // If false, let the browser handle the keys. 240 | // media_keys_are_fkeys = false 241 | 242 | // [bool] Set whether the meta key sends a leading escape or not. 243 | // meta_sends_escape = true 244 | 245 | // [enum(null, 0, 1, 2, 3, 4, 5, 6] 246 | // Mouse paste button, or null to autodetect. 247 | // For autodetect, we'll try to enable middle button paste for non-X11 platforms. 248 | // On X11 we move it to button 3. 249 | // mouse_paste_button = null 250 | 251 | // [bool] If true, page up/down will control the terminal scrollbar and shift page up/down will send the VT keycodes. 252 | // If false then page up/down sends VT codes and shift page up/down scrolls. 253 | // page_keys_scroll = false 254 | 255 | // [enum(null, true, false)] 256 | // Set whether we should pass Alt-1..9 to the browser. 257 | // This is handy when running hterm in a browser tab, so that you don't lose Chrome's "switch to tab" keyboard accelerators. 258 | // When not running in a tab it's better to send these keys to the host so they can be used in vim or emacs. 259 | // If true, Alt-1..9 will be handled by the browser. 260 | // If false, Alt-1..9 will be sent to the host. 261 | // If null, autodetect based on browser platform and window type. 262 | // pass_alt_number = null 263 | 264 | // [enum(null, true, false)] 265 | // Set whether we should pass Ctrl-1..9 to the browser. 266 | // This is handy when running hterm in a browser tab, so that you don't lose Chrome's "switch to tab" keyboard accelerators. 267 | // When not running in a tab it's better to send these keys to the host so they can be used in vim or emacs. 268 | // If true, Ctrl-1..9 will be handled by the browser. 269 | // If false, Ctrl-1..9 will be sent to the host. 270 | // If null, autodetect based on browser platform and window type. 271 | // pass_ctrl_number = null 272 | 273 | // [enum(null, true, false)] 274 | // Set whether we should pass Meta-1..9 to the browser. 275 | // This is handy when running hterm in a browser tab, so that you don't lose Chrome's "switch to tab" keyboard accelerators. 276 | // When not running in a tab it's better to send these keys to the host so they can be used in vim or emacs. 277 | // If true, Meta-1..9 will be handled by the browser. 278 | // If false, Meta-1..9 will be sent to the host. If null, autodetect based on browser platform and window type. 279 | // pass_meta_number = null 280 | 281 | // [bool] Set whether meta-V gets passed to host. 282 | // pass_meta_v = true 283 | 284 | // [bool] If true, scroll to the bottom on any keystroke. 285 | // scroll_on_keystroke = true 286 | 287 | // [bool] If true, scroll to the bottom on terminal output. 288 | // scroll_on_output = false 289 | 290 | // [bool] The vertical scrollbar mode. 291 | // scrollbar_visible = true 292 | 293 | // [int] The multiplier for the pixel delta in mousewheel event caused by the scroll wheel. Alters how fast the page scrolls. 294 | // scroll_wheel_move_multiplier = 1 295 | 296 | // [bool] Shift + Insert pastes if true, sent to host if false. 297 | // shift_insert_paste = true 298 | 299 | // [string] URL of user stylesheet to include in the terminal document. 300 | // user_css = "" 301 | 302 | // } 303 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | # 'git shortlog --help' and look for mailmap for the format of each line 2 | 3 | # Email consolidation: 4 | # 5 | 6 | 7 | # Name consolidation: 8 | # Preferred author spelling 9 | Søren L. Hansen 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "amodio.tsl-problem-matcher" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch GoTTY", 9 | "type": "go", 10 | "buildFlags": "-tags=dev", 11 | "request": "launch", 12 | "mode": "debug", 13 | "program": "${workspaceFolder}", 14 | "args": ["-a", "127.0.0.1", "-w", "${env:SHELL}"] 15 | }, 16 | { 17 | "name": "Launch Chrome", 18 | "type": "chrome", 19 | "url": "http://127.0.0.1:8080", 20 | "webRoot": "${workspaceFolder}/js", 21 | "outFiles": [ 22 | "${workspaceFolder}/**/*.js", 23 | "!**/node_modules/**" 24 | ], 25 | }, 26 | ] 27 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "webpack watch", 8 | "type": "shell", 9 | "command": "cd ${workspaceFolder}/js;DEV=1 npx webpack --watch --mode=development", 10 | "problemMatcher": [ 11 | "$ts-webpack-watch" 12 | ], 13 | "isBackground": true 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | GoTTY is MIT licensed and accepts contributions via GitHub pull requests. We also accepts feature requests on GitHub issues. 4 | 5 | ## Reporting a bug 6 | 7 | Reporting a bug is always welcome and one of the best ways to contribute. A good bug report helps the developers to improve the product much easier. We therefore would like to ask you to fill out the quesions on the issue template as much as possible. That helps us to figure out what's happening and discover the root cause. 8 | 9 | 10 | ## Requesting a new feature 11 | 12 | When you find that GoTTY cannot fullfill your requirements because of lack of ability, you may want to open a new feature request. In that case, please file a new issue with your usecase and requirements. 13 | 14 | 15 | ## Opening a pull request 16 | 17 | ### Code Style 18 | 19 | Please run `go fmt` on your Go code and make sure that your commits are organized for each logical change and your commit messages are in proper format (see below). 20 | 21 | [Go's official code style guide](https://github.com/golang/go/wiki/CodeReviewComments) is also helpful. 22 | 23 | ### Format of the commit message 24 | 25 | When you write a commit message, we recommend include following information to make review easier and keep the history cleaerer. 26 | 27 | * What is the change 28 | * The reason for the change 29 | 30 | The following is an example: 31 | 32 | ``` 33 | Add something new to existing package 34 | 35 | Since the existing function lacks that mechanism for some purpose, 36 | this commit adds a new structure to provide it. 37 | ``` 38 | 39 | When your pull request is to add a new feature, we recommend add an actual usecase so that we can discuss the best way to achive your requirement. Opening a proposal issue in advance is another good way to start discussion of new features. 40 | 41 | 42 | ## Contact 43 | 44 | If you have a trivial question about GoTTY for a bug or new feature, you can contact @sorenisanerd on Twitter (unfortunately, I cannot provide support on GoTTY though). 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 as js-build 2 | WORKDIR /gotty 3 | COPY js /gotty/js 4 | COPY Makefile /gotty/ 5 | RUN make bindata/static/js/gotty.js.map 6 | 7 | FROM golang:1.20 as go-build 8 | WORKDIR /gotty 9 | COPY . /gotty 10 | COPY --from=js-build /gotty/js/node_modules /gotty/js/node_modules 11 | COPY --from=js-build /gotty/bindata/static/js /gotty/bindata/static/js 12 | RUN CGO_ENABLED=0 make 13 | 14 | FROM alpine:latest 15 | RUN apk update && \ 16 | apk upgrade && \ 17 | apk --no-cache add ca-certificates bash 18 | WORKDIR /root 19 | COPY --from=go-build /gotty/gotty /usr/bin/ 20 | CMD ["gotty", "-w", "bash"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2017 Iwasaki Yudai 4 | Copyright (c) 2021-2022 Søren L. Hansen 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OUTPUT_DIR = ./builds 2 | GIT_COMMIT = `git rev-parse HEAD | cut -c1-7` 3 | VERSION = $(shell git describe --tags) 4 | BUILD_OPTIONS = -ldflags "-X main.Version=$(VERSION)" 5 | 6 | ifeq ($(DEV), 1) 7 | BUILD_OPTIONS += -tags dev 8 | WEBPACK_MODE = development 9 | else 10 | WEBPACK_MODE = production 11 | endif 12 | 13 | export CGO_ENABLED=0 14 | 15 | gotty: main.go assets server/*.go webtty/*.go backend/*.go Makefile 16 | go build ${BUILD_OPTIONS} 17 | 18 | docker: 19 | docker build . -t gotty-bash:$(VERSION) 20 | 21 | .PHONY: all docker assets 22 | assets: bindata/static/js/gotty.js.map \ 23 | bindata/static/js/gotty.js \ 24 | bindata/static/index.html \ 25 | bindata/static/icon.svg \ 26 | bindata/static/favicon.ico \ 27 | bindata/static/css/index.css \ 28 | bindata/static/css/xterm.css \ 29 | bindata/static/css/xterm_customize.css \ 30 | bindata/static/manifest.json \ 31 | bindata/static/icon_192.png 32 | 33 | all: gotty 34 | 35 | bindata/static bindata/static/css bindata/static/js: 36 | mkdir -p $@ 37 | 38 | bindata/static/%: resources/% | bindata/static/css 39 | cp "$<" "$@" 40 | 41 | bindata/static/css/%.css: resources/%.css | bindata/static 42 | cp "$<" "$@" 43 | 44 | bindata/static/css/xterm.css: js/node_modules/xterm/css/xterm.css | bindata/static 45 | cp "$<" "$@" 46 | 47 | js/node_modules/xterm/dist/xterm.css: 48 | cd js && \ 49 | npm install 50 | 51 | bindata/static/js/gotty.js.map bindata/static/js/gotty.js: js/src/* | js/node_modules/webpack 52 | cd js && \ 53 | npx webpack --mode=$(WEBPACK_MODE) 54 | 55 | js/node_modules/webpack: 56 | cd js && \ 57 | npm install 58 | 59 | README-options: 60 | ./gotty --help | sed '1,/GLOBAL OPTIONS/ d' > options.txt.tmp 61 | sed -f README.md.sed -i README.md 62 | rm options.txt.tmp 63 | 64 | tools: 65 | go install github.com/mitchellh/gox@latest 66 | go install github.com/tcnksm/ghr@latest 67 | 68 | test: 69 | if [ `go fmt $(go list ./... | grep -v /vendor/) | wc -l` -gt 0 ]; then echo "go fmt error"; exit 1; fi 70 | go test ./... 71 | 72 | cross_compile: 73 | GOARM=5 gox -os="darwin linux freebsd netbsd openbsd solaris" -arch="386 amd64 arm arm64" -osarch="!darwin/386" -osarch="!darwin/arm" $(BUILD_OPTIONS) -output "${OUTPUT_DIR}/pkg/{{.OS}}_{{.Arch}}/{{.Dir}}" 74 | 75 | targz: 76 | mkdir -p ${OUTPUT_DIR}/dist 77 | cd ${OUTPUT_DIR}/pkg/; for osarch in *; do (cd $$osarch; tar zcvf ../../dist/gotty_${VERSION}_$$osarch.tar.gz ./*); done; 78 | 79 | shasums: 80 | cd ${OUTPUT_DIR}/dist; sha256sum * > ./SHA256SUMS 81 | 82 | release-artifacts: gotty cross_compile targz shasums 83 | 84 | release: 85 | ghr -draft ${VERSION} ${OUTPUT_DIR}/dist # -c ${GIT_COMMIT} --delete --prerelease -u sorenisanerd -r gotty ${VERSION} 86 | 87 | clean: 88 | rm -fr gotty builds js/dist bindata/static js/node_modules 89 | 90 | addcontributors: 91 | gh issue list -s all -L 1000 --json author -t "$$(echo '{{ range . }}{{ .author.login }}\n{{ end }}')" | sort | uniq | xargs -Ifoo all-contributors add foo bug --commitTemplate '<%= (newContributor ? "Add" : "Update") %> @<%= username %> as a contributor' 92 | gh pr list -s all -L 1000 --json author -t "$$(echo '{{ range . }}{{ .author.login }}\n{{ end }}')" | sort | uniq | xargs -Ifoo all-contributors add foo code --commitTemplate '<%= (newContributor ? "Add" : "Update") %> @<%= username %> as a contributor' -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | ## 1.5.0 (2022-09-01) 2 | 3 | * Add @ahmetb as a contributor ([276767a](https://github.com/sorenisanerd/gotty/commit/276767a)) 4 | * Add @CoconutMacaroon as a contributor ([74c1318](https://github.com/sorenisanerd/gotty/commit/74c1318)) 5 | * Add @DannyBen as a contributor ([fcfa161](https://github.com/sorenisanerd/gotty/commit/fcfa161)) 6 | * Add @dmartin as a contributor ([6b2ae89](https://github.com/sorenisanerd/gotty/commit/6b2ae89)) 7 | * Add @Fan-SJ as a contributor ([c2428c8](https://github.com/sorenisanerd/gotty/commit/c2428c8)) 8 | * Add @flechaig as a contributor ([2c4004d](https://github.com/sorenisanerd/gotty/commit/2c4004d)) 9 | * Add @George-NG as a contributor ([270ae45](https://github.com/sorenisanerd/gotty/commit/270ae45)) 10 | * Add @ghthor as a contributor ([1a6bccd](https://github.com/sorenisanerd/gotty/commit/1a6bccd)) 11 | * Add @jpillora as a contributor ([f52fbd7](https://github.com/sorenisanerd/gotty/commit/f52fbd7)) 12 | * Add @kaisawind as a contributor ([accff3a](https://github.com/sorenisanerd/gotty/commit/accff3a)) 13 | * Add @linyinli as a contributor ([8014af3](https://github.com/sorenisanerd/gotty/commit/8014af3)) 14 | * Add @LucaMarconato as a contributor ([3bd9836](https://github.com/sorenisanerd/gotty/commit/3bd9836)) 15 | * Add @masterkain as a contributor ([369e2f7](https://github.com/sorenisanerd/gotty/commit/369e2f7)) 16 | * Add @Nexuist as a contributor ([ca691bc](https://github.com/sorenisanerd/gotty/commit/ca691bc)) 17 | * Add @qigj as a contributor ([5a052e7](https://github.com/sorenisanerd/gotty/commit/5a052e7)) 18 | * Add @shuaiyy as a contributor ([95e1bbd](https://github.com/sorenisanerd/gotty/commit/95e1bbd)) 19 | * Add @v20z as a contributor ([d594bef](https://github.com/sorenisanerd/gotty/commit/d594bef)) 20 | * Add @xgdgsc as a contributor ([c197990](https://github.com/sorenisanerd/gotty/commit/c197990)) 21 | * Add @Yann-Qiu as a contributor ([7b994ec](https://github.com/sorenisanerd/gotty/commit/7b994ec)) 22 | * Update @flechaig as a contributor ([9877e9c](https://github.com/sorenisanerd/gotty/commit/9877e9c)) 23 | * Update @flechaig as a contributor ([e03ea9c](https://github.com/sorenisanerd/gotty/commit/e03ea9c)) 24 | * Update @prusnak as a contributor ([3c45888](https://github.com/sorenisanerd/gotty/commit/3c45888)) 25 | * Update @prusnak as a contributor ([8d7f5fc](https://github.com/sorenisanerd/gotty/commit/8d7f5fc)) 26 | * Update @sorenisanerd as a contributor ([34f516b](https://github.com/sorenisanerd/gotty/commit/34f516b)) 27 | * Update @sorenisanerd as a contributor ([89a04d1](https://github.com/sorenisanerd/gotty/commit/89a04d1)) 28 | * Update @xgdgsc as a contributor ([b3c5d03](https://github.com/sorenisanerd/gotty/commit/b3c5d03)) 29 | * Update @xgdgsc as a contributor ([09f7e95](https://github.com/sorenisanerd/gotty/commit/09f7e95)) 30 | * Add make target to add contributors ([1bbfd5e](https://github.com/sorenisanerd/gotty/commit/1bbfd5e)) 31 | * Add missing import "strings" ([d0e3ffb](https://github.com/sorenisanerd/gotty/commit/d0e3ffb)) 32 | * add slash ([7706bf2](https://github.com/sorenisanerd/gotty/commit/7706bf2)) 33 | * Always disable CGO ([7a96f37](https://github.com/sorenisanerd/gotty/commit/7a96f37)), closes [#39](https://github.com/sorenisanerd/gotty/issues/39) 34 | * Bump terser from 5.12.1 to 5.14.2 in /js ([3ae13e0](https://github.com/sorenisanerd/gotty/commit/3ae13e0)) 35 | * Create a release when a new tag is pushed ([d8fe975](https://github.com/sorenisanerd/gotty/commit/d8fe975)) 36 | * Ensure --quiet flag is honored ([7d431a7](https://github.com/sorenisanerd/gotty/commit/7d431a7)), closes [#45](https://github.com/sorenisanerd/gotty/issues/45) 37 | * Refresh dependencies, drop node-sass ([94e5873](https://github.com/sorenisanerd/gotty/commit/94e5873)) 38 | * Run tests on pull requests ([316d5ff](https://github.com/sorenisanerd/gotty/commit/316d5ff)) 39 | 40 | 41 | ## 1.4.0 (2022-05-30) 42 | 43 | * Add @hardliner66 as a contributor ([1ca998e](https://github.com/sorenisanerd/gotty/commit/1ca998e)) 44 | * Add @jkandasa as a contributor ([cd23910](https://github.com/sorenisanerd/gotty/commit/cd23910)) 45 | * Add backend tests ([603c650](https://github.com/sorenisanerd/gotty/commit/603c650)) 46 | * Add generated data to git ([a9fbc07](https://github.com/sorenisanerd/gotty/commit/a9fbc07)) 47 | * add quiet flag to disable logging ([4109b11](https://github.com/sorenisanerd/gotty/commit/4109b11)) 48 | * Add references to @yudai ([bffd821](https://github.com/sorenisanerd/gotty/commit/bffd821)), closes [#8](https://github.com/sorenisanerd/gotty/issues/8) 49 | * Add rule to build gotty.js.map ([82c3acf](https://github.com/sorenisanerd/gotty/commit/82c3acf)) 50 | * Apply font size and family in xterm ([f157dbe](https://github.com/sorenisanerd/gotty/commit/f157dbe)), closes [#21](https://github.com/sorenisanerd/gotty/issues/21) 51 | * Avoid HTTP 401 error on manifest.json due to CORS ([817b5c8](https://github.com/sorenisanerd/gotty/commit/817b5c8)) 52 | * Bump browserslist from 4.16.4 to 4.16.6 in /js ([8deba62](https://github.com/sorenisanerd/gotty/commit/8deba62)) 53 | * Disable arg passing by default ([5c8eb10](https://github.com/sorenisanerd/gotty/commit/5c8eb10)), closes [#17](https://github.com/sorenisanerd/gotty/issues/17) 54 | * Do not include ALL of bootstrap ([b63ea16](https://github.com/sorenisanerd/gotty/commit/b63ea16)) 55 | * Ensure defaults for booleans is set correctly ([28f8e61](https://github.com/sorenisanerd/gotty/commit/28f8e61)), closes [#16](https://github.com/sorenisanerd/gotty/issues/16) 56 | * Fix existing tests ([d674aa1](https://github.com/sorenisanerd/gotty/commit/d674aa1)), closes [#13](https://github.com/sorenisanerd/gotty/issues/13) 57 | * Fix warnings from Markdown linter ([aa86a34](https://github.com/sorenisanerd/gotty/commit/aa86a34)) 58 | * go fmt ([dcb153c](https://github.com/sorenisanerd/gotty/commit/dcb153c)) 59 | * Improve webtty test coverage ([f61763f](https://github.com/sorenisanerd/gotty/commit/f61763f)) 60 | * Make client request base64 encoding ([dd3603c](https://github.com/sorenisanerd/gotty/commit/dd3603c)) 61 | * Make sure we read the full message ([1eed97f](https://github.com/sorenisanerd/gotty/commit/1eed97f)) 62 | * Publish artifacts on push to master ([6c62ab7](https://github.com/sorenisanerd/gotty/commit/6c62ab7)) 63 | * Remove hterm ([163fd05](https://github.com/sorenisanerd/gotty/commit/163fd05)) 64 | * Run tests on push ([55674f1](https://github.com/sorenisanerd/gotty/commit/55674f1)) 65 | * Run tests on push to all branches ([679a324](https://github.com/sorenisanerd/gotty/commit/679a324)) 66 | * update go version in Dockerfile ([fd2fb99](https://github.com/sorenisanerd/gotty/commit/fd2fb99)) 67 | * Update js dependencies ([26fc412](https://github.com/sorenisanerd/gotty/commit/26fc412)) 68 | * Update xterm.js and other js libs ([81afdc7](https://github.com/sorenisanerd/gotty/commit/81afdc7)), closes [#18](https://github.com/sorenisanerd/gotty/issues/18) 69 | * Use bootstrap components for up- and downloads ([7f05f2f](https://github.com/sorenisanerd/gotty/commit/7f05f2f)) 70 | * Use Go's built-in embed mechanism ([f66f0d0](https://github.com/sorenisanerd/gotty/commit/f66f0d0)), closes [#7](https://github.com/sorenisanerd/gotty/issues/7) 71 | * feat(zmodem): Allow file uploads/downloads ([782991c](https://github.com/sorenisanerd/gotty/commit/782991c)) 72 | 73 | 74 | 75 | ## v1.3.0 76 | 77 | * Links in the tty are now clickable. 78 | * Use WebGL for rendering by default. 79 | * Ensure authentication (TLS or Basic auth) remain enabled even if some of the options are only given in config files Thanks, @devanlai! 80 | * Fix typo in README.md Thanks, @prusnak! 81 | * Add arm64/Linux build. Thanks for the suggestion, @nephaste! 82 | 83 | ## v1.2.0 84 | 85 | * Pass BUILD\_OPTIONS to gox, too, so release artifacts have version info included. 86 | * Update xterm.js 2.7.0 => 4.11.0 87 | * Lots of clean up. 88 | 89 | ## v1.1.0 90 | 91 | * Today I learned about Go's handling of versions, so re-releasing 2.1.0 as 1.1.0. 92 | * Added path option. Thanks, @apatil! 93 | 94 | ## v2.1.0 (whoops) 95 | 96 | * Use Go modules and update cli module import path. Thanks, @svanellewee! 97 | * Fix typos. Thanks, @0xflotus, @RealCyGuy, @ygit, @Jason-Cooke and @fredster33! 98 | * Fix printing of ipv6 addresses. Thanks, @Felixoid! 99 | * Add Progressive Web App support. Thanks, @sehaas! 100 | * Add instructions for GNU screen. Thanks, @Immortalin! 101 | * Add Solaris support. Thanks, @fazalmajid! 102 | * New maintainer: @sorenisanerd 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![](https://raw.githubusercontent.com/sorenisanerd/gotty/master/resources/favicon.ico) GoTTY - Share your terminal as a web application 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-57-orange.svg?style=flat-square)](#contributors-) 4 | 5 | 6 | [![GitHub release](http://img.shields.io/github/release/sorenisanerd/gotty.svg?style=flat-square)][release] 7 | [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)][license] 8 | [![Maintainer streaming](https://twitch-status.soren.tools/sorencodes)][twitch] 9 | 10 | [release]: https://github.com/sorenisanerd/gotty/releases 11 | [license]: https://github.com/sorenisanerd/gotty/blob/master/LICENSE 12 | [twitch]: https://twitch.tv/sorencodes 13 | 14 | GoTTY is a simple command line tool that turns your CLI tools into web applications. 15 | 16 | [Original work](https://github.com/yudai/gotty) by [Iwasaki Yudai](https://github.com/yudai). There would be no GoTTY without him. ❤️ 17 | 18 | ![Screenshot](https://raw.githubusercontent.com/sorenisanerd/gotty/master/screenshot.gif) 19 | 20 | # Installation 21 | 22 | ## From release page 23 | 24 | You can download the latest stable binary file from the [Releases](https://github.com/sorenisanerd/gotty/releases) page. Note that the release marked `Pre-release` is built for testing purpose, which can include unstable or breaking changes. Download a release marked [Latest release](https://github.com/sorenisanerd/gotty/releases/latest) for a stable build. 25 | 26 | (Files named with `darwin_amd64` are for Mac OS X users) 27 | 28 | ## Homebrew Installation 29 | 30 | You can install GoTTY with [Homebrew](http://brew.sh/) as well. 31 | 32 | ```sh 33 | $ brew install sorenisanerd/gotty/gotty 34 | ``` 35 | 36 | ## `go get` Installation (Development) 37 | 38 | If you have a Go language environment, you can install GoTTY with the `go get` command. However, this command builds a binary file from the latest master branch, which can include unstable or breaking changes. GoTTY requires go1.9 or later. 39 | 40 | ```sh 41 | $ go get github.com/sorenisanerd/gotty 42 | ``` 43 | 44 | # Usage 45 | 46 | ``` 47 | Usage: gotty [options] [] 48 | ``` 49 | 50 | Run `gotty` with your preferred command as its arguments (e.g. `gotty top`). 51 | 52 | By default, GoTTY starts a web server at port 8080. Open the URL on your web browser and you can see the running command as if it were running on your terminal. 53 | 54 | ## Options 55 | ```sh 56 | --address value, -a value IP address to listen (default: "0.0.0.0") [$GOTTY_ADDRESS] 57 | --port value, -p value Port number to liten (default: "8080") [$GOTTY_PORT] 58 | --path value, -m value Base path (default: "/") [$GOTTY_PATH] 59 | --permit-write, -w Permit clients to write to the TTY (BE CAREFUL) (default: false) [$GOTTY_PERMIT_WRITE] 60 | --credential value, -c value Credential for Basic Authentication (ex: user:pass, default disabled) [$GOTTY_CREDENTIAL] 61 | --random-url, -r Add a random string to the URL (default: false) [$GOTTY_RANDOM_URL] 62 | --random-url-length value Random URL length (default: 8) [$GOTTY_RANDOM_URL_LENGTH] 63 | --tls, -t Enable TLS/SSL (default: false) [$GOTTY_TLS] 64 | --tls-crt value TLS/SSL certificate file path (default: "~/.gotty.crt") [$GOTTY_TLS_CRT] 65 | --tls-key value TLS/SSL key file path (default: "~/.gotty.key") [$GOTTY_TLS_KEY] 66 | --tls-ca-crt value TLS/SSL CA certificate file for client certifications (default: "~/.gotty.ca.crt") [$GOTTY_TLS_CA_CRT] 67 | --index value Custom index.html file [$GOTTY_INDEX] 68 | --title-format value Title format of browser window (default: "{{ .command }}@{{ .hostname }}") [$GOTTY_TITLE_FORMAT] 69 | --reconnect Enable reconnection (default: false) [$GOTTY_RECONNECT] 70 | --reconnect-time value Time to reconnect (default: 10) [$GOTTY_RECONNECT_TIME] 71 | --max-connection value Maximum connection to gotty (default: 0) [$GOTTY_MAX_CONNECTION] 72 | --once Accept only one client and exit on disconnection (default: false) [$GOTTY_ONCE] 73 | --timeout value Timeout seconds for waiting a client(0 to disable) (default: 0) [$GOTTY_TIMEOUT] 74 | --permit-arguments Permit clients to send command line arguments in URL (e.g. http://example.com:8080/?arg=AAA&arg=BBB) (default: false) [$GOTTY_PERMIT_ARGUMENTS] 75 | --width value Static width of the screen, 0(default) means dynamically resize (default: 0) [$GOTTY_WIDTH] 76 | --height value Static height of the screen, 0(default) means dynamically resize (default: 0) [$GOTTY_HEIGHT] 77 | --ws-origin value A regular expression that matches origin URLs to be accepted by WebSocket. No cross origin requests are acceptable by default [$GOTTY_WS_ORIGIN] 78 | --ws-query-args value Querystring arguments to append to the websocket instantiation [$GOTTY_WS_QUERY_ARGS] 79 | --enable-webgl Enable WebGL renderer (default: true) [$GOTTY_ENABLE_WEBGL] 80 | --close-signal value Signal sent to the command process when gotty close it (default: SIGHUP) (default: 1) [$GOTTY_CLOSE_SIGNAL] 81 | --close-timeout value Time in seconds to force kill process after client is disconnected (default: -1) (default: -1) [$GOTTY_CLOSE_TIMEOUT] 82 | --config value Config file path (default: "~/.gotty") [$GOTTY_CONFIG] 83 | --help, -h show help (default: false) 84 | --version, -v print the version (default: false) 85 | ``` 86 | ### Config File 87 | You can customize default options and your terminal by providing a config file to the `gotty` command. GoTTY loads a profile file at `~/.gotty` by default when it exists. 88 | 89 | ``` 90 | // Listen at port 9000 by default 91 | port = "9000" 92 | 93 | // Enable TSL/SSL by default 94 | enable_tls = true 95 | 96 | ``` 97 | 98 | See the [`.gotty`](https://github.com/sorenisanerd/gotty/blob/master/.gotty) file in this repository for the list of configuration options. 99 | 100 | ### Security Options 101 | 102 | By default, GoTTY doesn't allow clients to send any keystrokes or commands except terminal window resizing. When you want to permit clients to write input to the TTY, add the `-w` option. However, accepting input from remote clients is dangerous for most commands. When you need interaction with the TTY for some reasons, consider starting GoTTY with tmux or GNU Screen and run your command on it (see "Sharing with Multiple Clients" section for detail). 103 | 104 | To restrict client access, you can use the `-c` option to enable the basic authentication. With this option, clients need to input the specified username and password to connect to the GoTTY server. Note that the credentials will be transmitted between the server and clients in plain text. For more strict authentication, consider the SSL/TLS client certificate authentication described below. 105 | 106 | The `-r` option is a little bit more casual way to restrict access. With this option, GoTTY generates a random URL so that only people who know the URL can get access to the server. 107 | 108 | All traffic between the server and clients are NOT encrypted by default. When you send secret information through GoTTY, we strongly recommend you use the `-t` option which enables TLS/SSL on the session. By default, GoTTY loads the crt and key files placed at `~/.gotty.crt` and `~/.gotty.key`. You can overwrite these file paths with the `--tls-crt` and `--tls-key` options. When you need to generate a self-signed certification file, you can use the `openssl` command. 109 | 110 | ```sh 111 | openssl req -x509 -nodes -days 9999 -newkey rsa:2048 -keyout ~/.gotty.key -out ~/.gotty.crt 112 | ``` 113 | 114 | (NOTE: For Safari uses, see [how to enable self-signed certificates for WebSockets](http://blog.marcon.me/post/24874118286/secure-websockets-safari) when use self-signed certificates) 115 | 116 | For additional security, you can use the SSL/TLS client certificate authentication by providing a CA certificate file to the `--tls-ca-crt` option (this option requires the `-t` or `--tls` to be set). This option requires all clients to send valid client certificates that are signed by the specified certification authority. 117 | 118 | ## Sharing with Multiple Clients 119 | 120 | GoTTY starts a new process with the given command when a new client connects to the server. This means users cannot share a single terminal with others by default. However, you can use terminal multiplexers for sharing a single process with multiple clients. 121 | ### Screen 122 | After installing GNU screen, start a new session with `screen -S name-for-session` and connect to it with gotty in another terminal window/tab through `screen -x name-for-session`. All commands and activities being done in the first terminal tab/window will now be broadcasted by gotty. 123 | ### Tmux 124 | For example, you can start a new tmux session named `gotty` with `top` command by the command below. 125 | 126 | ```sh 127 | $ gotty tmux new -A -s gotty top 128 | ``` 129 | 130 | This command doesn't allow clients to send keystrokes, however, you can attach the session from your local terminal and run operations like switching the mode of the `top` command. To connect to the tmux session from your terminal, you can use following command. 131 | 132 | ```sh 133 | $ tmux new -A -s gotty 134 | ``` 135 | 136 | By using terminal multiplexers, you can have the control of your terminal and allow clients to just see your screen. 137 | 138 | ### Quick Sharing on tmux 139 | 140 | To share your current session with others by a shortcut key, you can add a line like below to your `.tmux.conf`. 141 | 142 | ``` 143 | # Start GoTTY in a new window with C-t 144 | bind-key C-t new-window "gotty tmux attach -t `tmux display -p '#S'`" 145 | ``` 146 | 147 | ## Playing with Docker 148 | 149 | When you want to create a jailed environment for each client, you can use Docker containers like following: 150 | 151 | ```sh 152 | $ gotty -w docker run -it --rm busybox 153 | ``` 154 | 155 | ## Development 156 | 157 | You can build a binary by simply running `make`. go1.16 is required. 158 | 159 | To build the frontend part (JS files and other static files), you need `npm`. 160 | 161 | ## Architecture 162 | 163 | GoTTY uses [xterm.js](https://xtermjs.org/) to run a JavaScript based terminal on web browsers. GoTTY itself provides a websocket server that simply relays output from the TTY to clients and receives input from clients and forwards it to the TTY. This xterm + websocket idea is inspired by [Wetty](https://github.com/krishnasrinivas/wetty). 164 | 165 | ## Alternatives 166 | 167 | ### Command line client 168 | 169 | * [gotty-client](https://github.com/moul/gotty-client): If you want to connect to GoTTY server from your terminal 170 | 171 | ### Terminal/SSH on Web Browsers 172 | 173 | * [Secure Shell (Chrome App)](https://chrome.google.com/webstore/detail/secure-shell/pnhechapfaindjhompbnflcldabbghjo): If you are a chrome user and need a "real" SSH client on your web browser, perhaps the Secure Shell app is what you want 174 | * [Wetty](https://github.com/krishnasrinivas/wetty): Node based web terminal (SSH/login) 175 | * [ttyd](https://tsl0922.github.io/ttyd): C port of GoTTY with CJK and IME support 176 | 177 | ### Terminal Sharing 178 | 179 | * [tmate](http://tmate.io/): Forked-Tmux based Terminal-Terminal sharing 180 | * [termshare](https://termsha.re): Terminal-Terminal sharing through a HTTP server 181 | * [tmux](https://tmux.github.io/): Tmux itself also supports TTY sharing through SSH) 182 | 183 | # License 184 | 185 | The MIT License 186 | 187 | # Contributors 188 | 189 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 |

Iwasaki Yudai

💻

Soren L. Hansen

🐛

Andrea Lusuardi

💻

Manfred Touron

💻

Stephan

💻

Quentin Perez

💻

jzl

💻

Fazal Majid

💻

Immortalin

💻

freakhill

💻

0xflotus

💻

Andy Skelton

💻

Artem Medvedev

💻

Blake Jennings

💻

Christian Jensen

💻

Christopher Wilkinson

💻

Cyrus

💻

David Horsley

💻

Jason Cooke

💻

Denis Korenevskiy

💻

Massimiliano Stucchi

💻

Mikhail f. Shiryaev

💻

Robert Bittle

💻

sebastian haas

💻

shoji

💻

Shuanglei Tao

💻

The Gitter Badger

💻

Jacob Zhou

💻

zyfdegh

💻

fredster33

💻

mattn

💻

Shinichi Goto

💻

ygit

💻

Stéphane

🐛

Pavol Rusnak

🐛

Devan Lai

💻

Jeeva Kandasamy

💻

Steve Biedermann

💻

xgdgsc

🐛

flechaig

🐛

Fan-SJ

🐛

Dustin Martin

🐛

Ahmet Alp Balkan

🐛

CoconutMacaroon

🐛

Danny Ben Shitrit

🐛

George-NG

🐛

Will Owens

🐛

Jaime Pillora

🐛

kaisawind

🐛

linyinli

🐛

LucaMarconato

🐛

Kain

🐛

Andi Andreas

🐛

qigj

🐛

shuaiyy

🐛

v20z

🐛

Yanfeng Qiu

🐛
271 | 272 | 273 | 274 | 275 | 276 | 277 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 278 | -------------------------------------------------------------------------------- /README.md.sed: -------------------------------------------------------------------------------- 1 | /^## Options/,/^### Config File/ { 2 | /^\(`\|#\)/!d # Delete any line not beginning with ` or # 3 | /```sh/ { # Shove options.txt.tmp in after ```sh 4 | r options.txt.tmp 5 | } 6 | } -------------------------------------------------------------------------------- /backend/doc.go: -------------------------------------------------------------------------------- 1 | package backend 2 | -------------------------------------------------------------------------------- /backend/localcommand/doc.go: -------------------------------------------------------------------------------- 1 | // Package localcommand provides an implementation of webtty.Slave 2 | // that launches a local command with a PTY. 3 | package localcommand 4 | -------------------------------------------------------------------------------- /backend/localcommand/factory.go: -------------------------------------------------------------------------------- 1 | package localcommand 2 | 3 | import ( 4 | "syscall" 5 | "time" 6 | 7 | "github.com/sorenisanerd/gotty/server" 8 | ) 9 | 10 | type Options struct { 11 | CloseSignal int `hcl:"close_signal" flagName:"close-signal" flagSName:"" flagDescribe:"Signal sent to the command process when gotty close it (default: SIGHUP)" default:"1"` 12 | CloseTimeout int `hcl:"close_timeout" flagName:"close-timeout" flagSName:"" flagDescribe:"Time in seconds to force kill process after client is disconnected (default: -1)" default:"-1"` 13 | } 14 | 15 | type Factory struct { 16 | command string 17 | argv []string 18 | options *Options 19 | opts []Option 20 | } 21 | 22 | func NewFactory(command string, argv []string, options *Options) (*Factory, error) { 23 | opts := []Option{WithCloseSignal(syscall.Signal(options.CloseSignal))} 24 | if options.CloseTimeout >= 0 { 25 | opts = append(opts, WithCloseTimeout(time.Duration(options.CloseTimeout)*time.Second)) 26 | } 27 | 28 | return &Factory{ 29 | command: command, 30 | argv: argv, 31 | options: options, 32 | opts: opts, 33 | }, nil 34 | } 35 | 36 | func (factory *Factory) Name() string { 37 | return "local command" 38 | } 39 | 40 | func (factory *Factory) New(params map[string][]string, headers map[string][]string) (server.Slave, error) { 41 | argv := make([]string, len(factory.argv)) 42 | copy(argv, factory.argv) 43 | if params["arg"] != nil && len(params["arg"]) > 0 { 44 | argv = append(argv, params["arg"]...) 45 | } 46 | 47 | return New(factory.command, argv, headers, factory.opts...) 48 | } 49 | -------------------------------------------------------------------------------- /backend/localcommand/local_command.go: -------------------------------------------------------------------------------- 1 | package localcommand 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "strings" 7 | "syscall" 8 | "time" 9 | 10 | "github.com/creack/pty" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | const ( 15 | DefaultCloseSignal = syscall.SIGINT 16 | DefaultCloseTimeout = 10 * time.Second 17 | ) 18 | 19 | type LocalCommand struct { 20 | command string 21 | argv []string 22 | 23 | closeSignal syscall.Signal 24 | closeTimeout time.Duration 25 | 26 | cmd *exec.Cmd 27 | pty *os.File 28 | ptyClosed chan struct{} 29 | } 30 | 31 | func New(command string, argv []string, headers map[string][]string, options ...Option) (*LocalCommand, error) { 32 | cmd := exec.Command(command, argv...) 33 | 34 | cmd.Env = append(os.Environ(), "TERM=xterm-256color") 35 | 36 | // Combine headers into key=value pairs to set as env vars 37 | // Prefix the headers with "http_" so we don't overwrite any other env vars 38 | // which potentially has the same name and to bring these closer to what 39 | // a (F)CGI server would proxy to a backend service 40 | // Replace hyphen with underscore and make them all upper case 41 | for key, values := range headers { 42 | h := "HTTP_" + strings.Replace(strings.ToUpper(key), "-", "_", -1) + "=" + strings.Join(values, ",") 43 | // log.Printf("Adding header: %s", h) 44 | cmd.Env = append(cmd.Env, h) 45 | } 46 | 47 | pty, err := pty.Start(cmd) 48 | if err != nil { 49 | // todo close cmd? 50 | return nil, errors.Wrapf(err, "failed to start command `%s`", command) 51 | } 52 | ptyClosed := make(chan struct{}) 53 | 54 | lcmd := &LocalCommand{ 55 | command: command, 56 | argv: argv, 57 | 58 | closeSignal: DefaultCloseSignal, 59 | closeTimeout: DefaultCloseTimeout, 60 | 61 | cmd: cmd, 62 | pty: pty, 63 | ptyClosed: ptyClosed, 64 | } 65 | 66 | for _, option := range options { 67 | option(lcmd) 68 | } 69 | 70 | // When the process is closed by the user, 71 | // close pty so that Read() on the pty breaks with an EOF. 72 | go func() { 73 | defer func() { 74 | lcmd.pty.Close() 75 | close(lcmd.ptyClosed) 76 | }() 77 | 78 | lcmd.cmd.Wait() 79 | }() 80 | 81 | return lcmd, nil 82 | } 83 | 84 | func (lcmd *LocalCommand) Read(p []byte) (n int, err error) { 85 | return lcmd.pty.Read(p) 86 | } 87 | 88 | func (lcmd *LocalCommand) Write(p []byte) (n int, err error) { 89 | return lcmd.pty.Write(p) 90 | } 91 | 92 | func (lcmd *LocalCommand) Close() error { 93 | if lcmd.cmd != nil && lcmd.cmd.Process != nil { 94 | lcmd.cmd.Process.Signal(lcmd.closeSignal) 95 | } 96 | for { 97 | select { 98 | case <-lcmd.ptyClosed: 99 | return nil 100 | case <-lcmd.closeTimeoutC(): 101 | lcmd.cmd.Process.Signal(syscall.SIGKILL) 102 | } 103 | } 104 | } 105 | 106 | func (lcmd *LocalCommand) WindowTitleVariables() map[string]interface{} { 107 | return map[string]interface{}{ 108 | "command": lcmd.command, 109 | "argv": lcmd.argv, 110 | "pid": lcmd.cmd.Process.Pid, 111 | } 112 | } 113 | 114 | func (lcmd *LocalCommand) ResizeTerminal(width int, height int) error { 115 | window := pty.Winsize{ 116 | Rows: uint16(height), 117 | Cols: uint16(width), 118 | X: 0, 119 | Y: 0, 120 | } 121 | err := pty.Setsize(lcmd.pty, &window) 122 | if err != nil { 123 | return err 124 | } else { 125 | return nil 126 | } 127 | } 128 | 129 | func (lcmd *LocalCommand) closeTimeoutC() <-chan time.Time { 130 | if lcmd.closeTimeout >= 0 { 131 | return time.After(lcmd.closeTimeout) 132 | } 133 | 134 | return make(chan time.Time) 135 | } 136 | -------------------------------------------------------------------------------- /backend/localcommand/local_command_test.go: -------------------------------------------------------------------------------- 1 | package localcommand 2 | 3 | import ( 4 | "bytes" 5 | "reflect" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestNewFactory(t *testing.T) { 11 | factory, err := NewFactory("/bin/false", []string{}, &Options{CloseSignal: 123, CloseTimeout: 321}) 12 | if err != nil { 13 | t.Errorf("NewFactory() returned error") 14 | return 15 | } 16 | if factory.command != "/bin/false" { 17 | t.Errorf("factory.command = %v, expected %v", factory.command, "/bin/false") 18 | } 19 | if !reflect.DeepEqual(factory.argv, []string{}) { 20 | t.Errorf("factory.argv = %v, expected %v", factory.argv, []string{}) 21 | } 22 | if !reflect.DeepEqual(factory.options, &Options{CloseSignal: 123, CloseTimeout: 321}) { 23 | t.Errorf("factory.options = %v, expected %v", factory.options, &Options{}) 24 | } 25 | 26 | slave, _ := factory.New(nil, nil) 27 | lcmd := slave.(*LocalCommand) 28 | if lcmd.closeSignal != 123 { 29 | t.Errorf("lcmd.closeSignal = %v, expected %v", lcmd.closeSignal, 123) 30 | } 31 | if lcmd.closeTimeout != time.Second*321 { 32 | t.Errorf("lcmd.closeTimeout = %v, expected %v", lcmd.closeTimeout, time.Second*321) 33 | } 34 | } 35 | 36 | func TestFactoryNew(t *testing.T) { 37 | factory, err := NewFactory("/bin/cat", []string{}, &Options{}) 38 | if err != nil { 39 | t.Errorf("NewFactory() returned error") 40 | return 41 | } 42 | 43 | slave, err := factory.New(nil, nil) 44 | if err != nil { 45 | t.Errorf("factory.New() returned error") 46 | return 47 | } 48 | 49 | writeBuf := []byte("foobar\n") 50 | n, err := slave.Write(writeBuf) 51 | if err != nil { 52 | t.Errorf("write() failed: %v", err) 53 | return 54 | } 55 | if n != 7 { 56 | t.Errorf("Unexpected write length. n = %d, expected n = %d", n, 7) 57 | return 58 | } 59 | 60 | // Local echo is on, so we get the output twice: 61 | // Once because we're "typing" it, and once more 62 | // repeated back to us by `cat`. Also, \r\n 63 | // because we're a terminal. 64 | expectedBuf := []byte("foobar\r\nfoobar\r\n") 65 | readBuf := make([]byte, 1024) 66 | var totalRead int 67 | for totalRead < 16 { 68 | n, err = slave.Read(readBuf[totalRead:]) 69 | if err != nil { 70 | t.Errorf("read() failed: %v", err) 71 | return 72 | } 73 | totalRead += n 74 | } 75 | if totalRead != 16 { 76 | t.Errorf("Unexpected read length. totalRead = %d, expected totalRead = %d", totalRead, 16) 77 | return 78 | } 79 | if !bytes.Equal(readBuf[:totalRead], expectedBuf) { 80 | t.Errorf("unexpected output from slave: got %v, expected %v", readBuf[:totalRead], expectedBuf) 81 | } 82 | err = slave.Close() 83 | if err != nil { 84 | t.Errorf("close() failed: %v", err) 85 | return 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /backend/localcommand/options.go: -------------------------------------------------------------------------------- 1 | package localcommand 2 | 3 | import ( 4 | "syscall" 5 | "time" 6 | ) 7 | 8 | type Option func(*LocalCommand) 9 | 10 | func WithCloseSignal(signal syscall.Signal) Option { 11 | return func(lcmd *LocalCommand) { 12 | lcmd.closeSignal = signal 13 | } 14 | } 15 | 16 | func WithCloseTimeout(timeout time.Duration) Option { 17 | return func(lcmd *LocalCommand) { 18 | lcmd.closeTimeout = timeout 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /bindata/bindata.go: -------------------------------------------------------------------------------- 1 | //go:build !dev 2 | 3 | package bindata 4 | 5 | import "embed" 6 | 7 | //go:embed static/* 8 | var Fs embed.FS 9 | -------------------------------------------------------------------------------- /bindata/bindata_dev.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package bindata 4 | 5 | import ( 6 | "io" 7 | "io/fs" 8 | "os" 9 | ) 10 | 11 | type GottyFS struct { 12 | fs.FS 13 | } 14 | 15 | var Fs GottyFS 16 | 17 | func (gfs *GottyFS) ReadFile(name string) ([]byte, error) { 18 | fp, err := gfs.Open(name) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return io.ReadAll(fp) 24 | } 25 | 26 | func init() { 27 | Fs = GottyFS{os.DirFS("bindata")} 28 | } 29 | -------------------------------------------------------------------------------- /bindata/static/css/index.css: -------------------------------------------------------------------------------- 1 | html, body, #terminal { 2 | background: black; 3 | height: 100%; 4 | width: 100%; 5 | padding: 0%; 6 | margin: 0%; 7 | } 8 | 9 | .progress .progress-bar { 10 | transition: unset; 11 | transition-duration: 0.1s; 12 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 13 | } -------------------------------------------------------------------------------- /bindata/static/css/xterm.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2014 The xterm.js authors. All rights reserved. 3 | * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) 4 | * https://github.com/chjj/term.js 5 | * @license MIT 6 | * 7 | * Permission is hereby granted, free of charge, to any person obtaining a copy 8 | * of this software and associated documentation files (the "Software"), to deal 9 | * in the Software without restriction, including without limitation the rights 10 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | * copies of the Software, and to permit persons to whom the Software is 12 | * furnished to do so, subject to the following conditions: 13 | * 14 | * The above copyright notice and this permission notice shall be included in 15 | * all copies or substantial portions of the Software. 16 | * 17 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | * THE SOFTWARE. 24 | * 25 | * Originally forked from (with the author's permission): 26 | * Fabrice Bellard's javascript vt100 for jslinux: 27 | * http://bellard.org/jslinux/ 28 | * Copyright (c) 2011 Fabrice Bellard 29 | * The original design remains. The terminal itself 30 | * has been extended to include xterm CSI codes, among 31 | * other features. 32 | */ 33 | 34 | /** 35 | * Default styles for xterm.js 36 | */ 37 | 38 | .xterm { 39 | cursor: text; 40 | position: relative; 41 | user-select: none; 42 | -ms-user-select: none; 43 | -webkit-user-select: none; 44 | } 45 | 46 | .xterm.focus, 47 | .xterm:focus { 48 | outline: none; 49 | } 50 | 51 | .xterm .xterm-helpers { 52 | position: absolute; 53 | top: 0; 54 | /** 55 | * The z-index of the helpers must be higher than the canvases in order for 56 | * IMEs to appear on top. 57 | */ 58 | z-index: 5; 59 | } 60 | 61 | .xterm .xterm-helper-textarea { 62 | padding: 0; 63 | border: 0; 64 | margin: 0; 65 | /* Move textarea out of the screen to the far left, so that the cursor is not visible */ 66 | position: absolute; 67 | opacity: 0; 68 | left: -9999em; 69 | top: 0; 70 | width: 0; 71 | height: 0; 72 | z-index: -5; 73 | /** Prevent wrapping so the IME appears against the textarea at the correct position */ 74 | white-space: nowrap; 75 | overflow: hidden; 76 | resize: none; 77 | } 78 | 79 | .xterm .composition-view { 80 | /* TODO: Composition position got messed up somewhere */ 81 | background: #000; 82 | color: #FFF; 83 | display: none; 84 | position: absolute; 85 | white-space: nowrap; 86 | z-index: 1; 87 | } 88 | 89 | .xterm .composition-view.active { 90 | display: block; 91 | } 92 | 93 | .xterm .xterm-viewport { 94 | /* On OS X this is required in order for the scroll bar to appear fully opaque */ 95 | background-color: #000; 96 | overflow-y: scroll; 97 | cursor: default; 98 | position: absolute; 99 | right: 0; 100 | left: 0; 101 | top: 0; 102 | bottom: 0; 103 | } 104 | 105 | .xterm .xterm-screen { 106 | position: relative; 107 | } 108 | 109 | .xterm .xterm-screen canvas { 110 | position: absolute; 111 | left: 0; 112 | top: 0; 113 | } 114 | 115 | .xterm .xterm-scroll-area { 116 | visibility: hidden; 117 | } 118 | 119 | .xterm-char-measure-element { 120 | display: inline-block; 121 | visibility: hidden; 122 | position: absolute; 123 | top: 0; 124 | left: -9999em; 125 | line-height: normal; 126 | } 127 | 128 | .xterm.enable-mouse-events { 129 | /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ 130 | cursor: default; 131 | } 132 | 133 | .xterm.xterm-cursor-pointer, 134 | .xterm .xterm-cursor-pointer { 135 | cursor: pointer; 136 | } 137 | 138 | .xterm.column-select.focus { 139 | /* Column selection mode */ 140 | cursor: crosshair; 141 | } 142 | 143 | .xterm .xterm-accessibility, 144 | .xterm .xterm-message { 145 | position: absolute; 146 | left: 0; 147 | top: 0; 148 | bottom: 0; 149 | right: 0; 150 | z-index: 10; 151 | color: transparent; 152 | pointer-events: none; 153 | } 154 | 155 | .xterm .live-region { 156 | position: absolute; 157 | left: -9999px; 158 | width: 1px; 159 | height: 1px; 160 | overflow: hidden; 161 | } 162 | 163 | .xterm-dim { 164 | /* Dim should not apply to background, so the opacity of the foreground color is applied 165 | * explicitly in the generated class and reset to 1 here */ 166 | opacity: 1 !important; 167 | } 168 | 169 | .xterm-underline-1 { text-decoration: underline; } 170 | .xterm-underline-2 { text-decoration: double underline; } 171 | .xterm-underline-3 { text-decoration: wavy underline; } 172 | .xterm-underline-4 { text-decoration: dotted underline; } 173 | .xterm-underline-5 { text-decoration: dashed underline; } 174 | 175 | .xterm-overline { 176 | text-decoration: overline; 177 | } 178 | 179 | .xterm-overline.xterm-underline-1 { text-decoration: overline underline; } 180 | .xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } 181 | .xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } 182 | .xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } 183 | .xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } 184 | 185 | .xterm-strikethrough { 186 | text-decoration: line-through; 187 | } 188 | 189 | .xterm-screen .xterm-decoration-container .xterm-decoration { 190 | z-index: 6; 191 | position: absolute; 192 | } 193 | 194 | .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { 195 | z-index: 7; 196 | } 197 | 198 | .xterm-decoration-overview-ruler { 199 | z-index: 8; 200 | position: absolute; 201 | top: 0; 202 | right: 0; 203 | pointer-events: none; 204 | } 205 | 206 | .xterm-decoration-top { 207 | z-index: 2; 208 | position: relative; 209 | } 210 | -------------------------------------------------------------------------------- /bindata/static/css/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 | } -------------------------------------------------------------------------------- /bindata/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sorenisanerd/gotty/c69d11d17d83a86af4e0ea766d04b026ddb27c1f/bindata/static/favicon.ico -------------------------------------------------------------------------------- /bindata/static/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /bindata/static/icon_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sorenisanerd/gotty/c69d11d17d83a86af4e0ea766d04b026ddb27c1f/bindata/static/icon_192.png -------------------------------------------------------------------------------- /bindata/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ .title }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /bindata/static/js/gotty.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v5.3.2 (https://getbootstrap.com/) 3 | * Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | 7 | /*! crc32.js (C) 2014-present SheetJS -- http://sheetjs.com */ 8 | -------------------------------------------------------------------------------- /bindata/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "{{ .title }}", 3 | "name": "{{ .title }}", 4 | "start_url": "./", 5 | "icons": [ 6 | { 7 | "src": "./icon_192.png", 8 | "type": "image/png", 9 | "sizes": "192x192" 10 | } 11 | ], 12 | "display": "minimal-ui", 13 | "theme_color": "#000000", 14 | "background_color": "#000000" 15 | } 16 | -------------------------------------------------------------------------------- /favicon.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sorenisanerd/gotty/c69d11d17d83a86af4e0ea766d04b026ddb27c1f/favicon.ai -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sorenisanerd/gotty 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/NYTimes/gziphandler v1.1.1 7 | github.com/creack/pty v1.1.11 8 | github.com/fatih/structs v1.1.0 9 | github.com/gorilla/websocket v1.4.2 10 | github.com/pkg/errors v0.9.1 11 | github.com/urfave/cli/v2 v2.3.0 12 | github.com/yudai/hcl v0.0.0-20151013225006-5fa2393b3552 13 | ) 14 | 15 | require ( 16 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 17 | github.com/hashicorp/errwrap v1.0.0 // indirect 18 | github.com/hashicorp/go-multierror v1.1.1 // indirect 19 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 20 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 21 | golang.org/x/sys v0.1.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= 3 | github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 6 | github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= 7 | github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= 11 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 12 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 13 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 14 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 15 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 16 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 17 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 18 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 19 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 23 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 24 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 25 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 28 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 29 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= 30 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 31 | github.com/yudai/hcl v0.0.0-20151013225006-5fa2393b3552 h1:tjsK9T2IA3d2FFNxzDP7AJf+EXhyuPd7PB4Z2HrtAoc= 32 | github.com/yudai/hcl v0.0.0-20151013225006-5fa2393b3552/go.mod h1:hg0ZaCmQL3rze1cH8Fh2g0a9q8vQs0uN8ESpePEwSEw= 33 | golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= 34 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 37 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gotty", 3 | "version": "1.4.0", 4 | "private": true, 5 | "devDependencies": { 6 | "@types/bootstrap": "^5.2.10", 7 | "compression-webpack-plugin": "^9.2.0", 8 | "license-loader": "^0.5.0", 9 | "license-webpack-plugin": "^4.0.2", 10 | "purgecss": "^4.1.3", 11 | "sass": "^1.70.0", 12 | "sass-loader": "^12.6.0", 13 | "terser-webpack-plugin": "^5.3.10", 14 | "ts-loader": "^8.4.0", 15 | "typescript": "^4.9.5", 16 | "webpack": "^5.90.1", 17 | "webpack-cli": "^4.10.0", 18 | "webpack-dev-server": "^4.15.1" 19 | }, 20 | "dependencies": { 21 | "@popperjs/core": "^2.11.8", 22 | "bootstrap": "^5.3.2", 23 | "css-loader": "^5.2.7", 24 | "debounce": "^1.2.1", 25 | "preact": "^10.19.4", 26 | "react-bootstrap": "^2.10.1", 27 | "style-loader": "^2.0.0", 28 | "xterm": "^5.3.0", 29 | "xterm-addon-fit": "^0.8.0", 30 | "xterm-addon-web-links": "^0.9.0", 31 | "xterm-addon-webgl": "^0.16.0", 32 | "zmodem.js": "^0.1.10" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /js/src/MyModal.tsx: -------------------------------------------------------------------------------- 1 | import { createRef, Component, ComponentChildren } from "preact"; 2 | import { Modal } from "bootstrap"; 3 | import './bootstrap.scss'; 4 | 5 | interface ModalProps { 6 | children: ComponentChildren; 7 | buttons?: ComponentChildren; 8 | title: string; 9 | dismissHandler?: (hideModal?: () => void) => void; 10 | } 11 | 12 | export class MyModal extends Component { 13 | ref = createRef(); 14 | 15 | constructor() { 16 | super(); 17 | } 18 | 19 | componentDidMount() { 20 | Modal.getOrCreateInstance(this.ref.current!).show(); 21 | this.ref.current?.addEventListener('hide.bs.modal', () => { this.props.dismissHandler && this.props.dismissHandler(); }); 22 | } 23 | 24 | componentWillUnmount() { 25 | this.hide() 26 | } 27 | 28 | hide(): void { 29 | Modal.getOrCreateInstance(this.ref.current!).hide(); 30 | } 31 | 32 | render() { 33 | return ; 49 | } 50 | } 51 | 52 | interface ButtonProps { 53 | priority: "primary" | "secondary" | "danger" 54 | clickHandler?: () => void 55 | children: ComponentChildren 56 | disabled?: boolean; 57 | } 58 | 59 | export function Button(props:ButtonProps) { 60 | let classes: string = "btn btn-" + props.priority 61 | 62 | return 63 | } 64 | -------------------------------------------------------------------------------- /js/src/bootstrap.scss: -------------------------------------------------------------------------------- 1 | @import "mixins/banner"; 2 | @include bsBanner(""); 3 | 4 | 5 | // scss-docs-start import-stack 6 | // Configuration 7 | @import "functions"; 8 | @import "variables"; 9 | @import "variables-dark"; 10 | @import "maps"; 11 | @import "mixins"; 12 | @import "utilities"; 13 | 14 | // Layout & components 15 | @import "root"; 16 | @import "reboot"; 17 | @import "type"; 18 | @import "images"; 19 | @import "containers"; 20 | @import "grid"; 21 | @import "buttons"; 22 | @import "transitions"; 23 | @import "progress"; 24 | @import "close"; 25 | @import "modal"; 26 | @import "spinners"; 27 | -------------------------------------------------------------------------------- /js/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionFactory } from "./websocket"; 2 | import { Terminal, WebTTY, protocols } from "./webtty"; 3 | import { OurXterm } from "./xterm"; 4 | 5 | // @TODO remove these 6 | declare var gotty_auth_token: string; 7 | declare var gotty_term: string; 8 | declare var gotty_ws_query_args: string; 9 | 10 | const elem = document.getElementById("terminal") 11 | 12 | if (elem !== null) { 13 | var term: Terminal; 14 | term = new OurXterm(elem); 15 | 16 | const httpsEnabled = window.location.protocol == "https:"; 17 | const queryArgs = (gotty_ws_query_args === "") ? "" : "?" + gotty_ws_query_args; 18 | const url = (httpsEnabled ? 'wss://' : 'ws://') + window.location.host + window.location.pathname + 'ws' + queryArgs; 19 | const args = window.location.search; 20 | const factory = new ConnectionFactory(url, protocols); 21 | const wt = new WebTTY(term, factory, args, gotty_auth_token); 22 | const closer = wt.open(); 23 | 24 | // According to https://developer.mozilla.org/en-US/docs/Web/API/Window/unload_event 25 | // this event is unreliable and in some cases (Firefox is mentioned), having an 26 | // "unload" event handler can have unwanted side effects. Consider commenting it out. 27 | window.addEventListener("unload", () => { 28 | closer(); 29 | term.close(); 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /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 | constructor(url: string, protocols: string[]) { 19 | this.bare = new WebSocket(url, protocols); 20 | } 21 | 22 | open() { 23 | // nothing todo for websocket 24 | }; 25 | 26 | close() { 27 | this.bare.close(); 28 | }; 29 | 30 | send(data: string) { 31 | this.bare.send(data); 32 | }; 33 | 34 | isOpen(): boolean { 35 | if (this.bare.readyState == WebSocket.CONNECTING || 36 | this.bare.readyState == WebSocket.OPEN) { 37 | return true 38 | } 39 | return false 40 | } 41 | 42 | onOpen(callback: () => void) { 43 | this.bare.onopen = (event) => { 44 | callback(); 45 | } 46 | }; 47 | 48 | onReceive(callback: (data: string) => void) { 49 | this.bare.onmessage = (event) => { 50 | callback(event.data); 51 | } 52 | }; 53 | 54 | onClose(callback: () => void) { 55 | this.bare.onclose = (event) => { 56 | callback(); 57 | }; 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /js/src/webtty.tsx: -------------------------------------------------------------------------------- 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 | export const msgSetEncoding = '4'; 8 | 9 | export const msgUnknownOutput = '0'; 10 | export const msgOutput = '1'; 11 | export const msgPong = '2'; 12 | export const msgSetWindowTitle = '3'; 13 | export const msgSetPreferences = '4'; 14 | export const msgSetReconnect = '5'; 15 | export const msgSetBufferSize = '6'; 16 | 17 | 18 | export interface Terminal { 19 | /* 20 | * Get dimensions of the terminal 21 | */ 22 | info(): { columns: number, rows: number }; 23 | 24 | /* 25 | * Process output from the server side 26 | */ 27 | output(data: Uint8Array): void; 28 | 29 | /* 30 | * Display a message overlay on the terminal 31 | */ 32 | showMessage(message: string, timeout: number): void; 33 | 34 | // Don't think we need this anymore 35 | // getMessage(): HTMLElement; 36 | 37 | /* 38 | * Remove message shown by shoMessage. You only need to call 39 | * this if you want to dismiss it sooner than the timeout. 40 | */ 41 | removeMessage(): void; 42 | 43 | 44 | /* 45 | * Set window title 46 | */ 47 | setWindowTitle(title: string): void; 48 | 49 | /* 50 | * Set preferences. TODO: Add typings 51 | */ 52 | setPreferences(value: object): void; 53 | 54 | 55 | /* 56 | * Sets an input (e.g. user types something) handler 57 | */ 58 | onInput(callback: (input: string) => void): void; 59 | 60 | /* 61 | * Sets a resize handler 62 | */ 63 | onResize(callback: (colmuns: number, rows: number) => void): void; 64 | 65 | reset(): void; 66 | deactivate(): void; 67 | close(): void; 68 | } 69 | 70 | export interface Connection { 71 | open(): void; 72 | close(): void; 73 | 74 | /* 75 | * This takes fucking strings?? 76 | */ 77 | send(s: string): void; 78 | 79 | isOpen(): boolean; 80 | onOpen(callback: () => void): void; 81 | onReceive(callback: (data: string) => void): void; 82 | onClose(callback: () => void): void; 83 | } 84 | 85 | export interface ConnectionFactory { 86 | create(): Connection; 87 | } 88 | 89 | export class WebTTY { 90 | /* 91 | * A terminal instance that implements the Terminal interface. 92 | * This made a lot of sense when we had both HTerm and xterm, but 93 | * now I wonder if the abstraction makes sense. Keeping it for now, 94 | * though. 95 | */ 96 | term: Terminal; 97 | 98 | /* 99 | * ConnectionFactory and connection instance. We pass the factory 100 | * in instead of just a connection so that we can reconnect. 101 | */ 102 | connectionFactory: ConnectionFactory; 103 | connection: Connection; 104 | 105 | /* 106 | * Arguments passed in by the user. We forward them to the backend 107 | * where they are appended to the command line. 108 | */ 109 | args: string; 110 | 111 | /* 112 | * An authentication token. The client gets this from `/auth_token.js`. 113 | */ 114 | authToken: string; 115 | 116 | /* 117 | * If connection is dropped, reconnect after `reconnect` seconds. 118 | * -1 means do not reconnect. 119 | */ 120 | reconnect: number; 121 | 122 | /* 123 | * The server's buffer size. If a single message exceeds this size, it will 124 | * be truncated on the server, so we track it here so that we can split messages 125 | * into chunks small enough that we don't hurt the server's feelings. 126 | */ 127 | bufSize: number; 128 | 129 | constructor(term: Terminal, connectionFactory: ConnectionFactory, args: string, authToken: string) { 130 | this.term = term; 131 | this.connectionFactory = connectionFactory; 132 | this.args = args; 133 | this.authToken = authToken; 134 | this.reconnect = -1; 135 | this.bufSize = 1024; 136 | }; 137 | 138 | open() { 139 | let connection = this.connectionFactory.create(); 140 | let pingTimer: NodeJS.Timeout; 141 | let reconnectTimeout: NodeJS.Timeout; 142 | this.connection = connection; 143 | 144 | const setup = () => { 145 | connection.onOpen(() => { 146 | const termInfo = this.term.info(); 147 | 148 | this.initializeConnection(this.args, this.authToken); 149 | 150 | this.term.onResize((columns: number, rows: number) => { 151 | this.sendResizeTerminal(columns, rows); 152 | }); 153 | 154 | this.sendResizeTerminal(termInfo.columns, termInfo.rows); 155 | 156 | this.sendSetEncoding("base64"); 157 | 158 | this.term.onInput( 159 | (input: string | Uint8Array) => { 160 | this.sendInput(input); 161 | } 162 | ); 163 | 164 | pingTimer = setInterval(() => { 165 | this.sendPing() 166 | }, 30 * 1000); 167 | }); 168 | 169 | connection.onReceive((data) => { 170 | const payload = data.slice(1); 171 | switch (data[0]) { 172 | case msgOutput: 173 | this.term.output(Uint8Array.from(atob(payload), c => c.charCodeAt(0))); 174 | break; 175 | case msgPong: 176 | break; 177 | case msgSetWindowTitle: 178 | this.term.setWindowTitle(payload); 179 | break; 180 | case msgSetPreferences: 181 | const preferences = JSON.parse(payload); 182 | this.term.setPreferences(preferences); 183 | break; 184 | case msgSetReconnect: 185 | const autoReconnect = JSON.parse(payload); 186 | console.log("Enabling reconnect: " + autoReconnect + " seconds") 187 | this.reconnect = autoReconnect; 188 | break; 189 | case msgSetBufferSize: 190 | const bufSize = JSON.parse(payload); 191 | this.bufSize = bufSize; 192 | break; 193 | } 194 | }); 195 | 196 | connection.onClose(() => { 197 | clearInterval(pingTimer); 198 | this.term.deactivate(); 199 | this.term.showMessage("Connection Closed", 0); 200 | if (this.reconnect > 0) { 201 | reconnectTimeout = setTimeout(() => { 202 | connection = this.connectionFactory.create(); 203 | this.term.reset(); 204 | setup(); 205 | }, this.reconnect * 1000); 206 | } 207 | }); 208 | 209 | connection.open(); 210 | } 211 | 212 | setup(); 213 | return () => { 214 | clearTimeout(reconnectTimeout); 215 | connection.close(); 216 | } 217 | }; 218 | 219 | private initializeConnection(args, authToken) { 220 | this.connection.send(JSON.stringify( 221 | { 222 | Arguments: args, 223 | AuthToken: authToken, 224 | } 225 | )); 226 | } 227 | 228 | /* 229 | * sendInput sends data to the server. It accepts strings or Uint8Arrays. 230 | * strings will be encoded as UTF-8. Uint8Arrays are passed along as-is. 231 | */ 232 | private sendInput(input: string | Uint8Array) { 233 | let effectiveBufferSize = this.bufSize - 1; 234 | let dataString: string; 235 | 236 | if (typeof input === "string") { 237 | dataString = input; 238 | } else { 239 | dataString = String.fromCharCode(...input) 240 | } 241 | 242 | // Account for base64 encoding 243 | let maxChunkSize = Math.floor(effectiveBufferSize / 4) * 3; 244 | 245 | for (let i = 0; i < Math.ceil(dataString.length / maxChunkSize); i++) { 246 | let inputChunk = dataString.substring(i * maxChunkSize, Math.min((i + 1) * maxChunkSize, dataString.length)) 247 | this.connection.send(msgInput + btoa(inputChunk)); 248 | } 249 | } 250 | 251 | private sendPing(): void { 252 | this.connection.send(msgPing); 253 | } 254 | 255 | private sendResizeTerminal(colmuns: number, rows: number) { 256 | this.connection.send( 257 | msgResizeTerminal + JSON.stringify( 258 | { 259 | columns: colmuns, 260 | rows: rows 261 | } 262 | ) 263 | ); 264 | } 265 | 266 | private sendSetEncoding(encoding: "base64" | "null") { 267 | this.connection.send(msgSetEncoding + encoding) 268 | } 269 | 270 | }; 271 | -------------------------------------------------------------------------------- /js/src/xterm.tsx: -------------------------------------------------------------------------------- 1 | import { IDisposable, Terminal } from "xterm"; 2 | import { FitAddon } from 'xterm-addon-fit'; 3 | import { WebLinksAddon } from 'xterm-addon-web-links'; 4 | import { WebglAddon } from 'xterm-addon-webgl'; 5 | import { ZModemAddon } from "./zmodem"; 6 | 7 | export class OurXterm { 8 | // The HTMLElement that contains our terminal 9 | elem: HTMLElement; 10 | 11 | // The xtermjs.XTerm 12 | term: Terminal; 13 | 14 | resizeListener: () => void; 15 | 16 | message: HTMLElement; 17 | messageTimeout: number; 18 | messageTimer: NodeJS.Timeout; 19 | 20 | onResizeHandler: IDisposable; 21 | onDataHandler: IDisposable; 22 | 23 | fitAddOn: FitAddon; 24 | zmodemAddon: ZModemAddon; 25 | toServer: (data: string | Uint8Array) => void; 26 | encoder: TextEncoder 27 | 28 | constructor(elem: HTMLElement) { 29 | this.elem = elem; 30 | this.term = new Terminal(); 31 | this.fitAddOn = new FitAddon(); 32 | this.zmodemAddon = new ZModemAddon({ 33 | toTerminal: (x: Uint8Array) => this.term.write(x), 34 | toServer: (x: Uint8Array) => this.sendInput(x) 35 | }); 36 | this.term.loadAddon(new WebLinksAddon()); 37 | this.term.loadAddon(this.fitAddOn); 38 | this.term.loadAddon(this.zmodemAddon); 39 | 40 | this.message = elem.ownerDocument.createElement("div"); 41 | this.message.className = "xterm-overlay"; 42 | this.messageTimeout = 2000; 43 | 44 | this.resizeListener = () => { 45 | this.fitAddOn.fit(); 46 | this.term.scrollToBottom(); 47 | this.showMessage(String(this.term.cols) + "x" + String(this.term.rows), this.messageTimeout); 48 | }; 49 | 50 | this.term.open(elem); 51 | this.term.focus(); 52 | this.resizeListener(); 53 | 54 | window.addEventListener("resize", () => { this.resizeListener(); }); 55 | }; 56 | 57 | info(): { columns: number, rows: number } { 58 | return { columns: this.term.cols, rows: this.term.rows }; 59 | }; 60 | 61 | // This gets called from the Websocket's onReceive handler 62 | output(data: Uint8Array) { 63 | this.zmodemAddon.consume(data); 64 | }; 65 | 66 | getMessage(): HTMLElement { 67 | return this.message; 68 | } 69 | 70 | showMessage(message: string, timeout: number) { 71 | this.message.innerHTML = message; 72 | this.showMessageElem(timeout); 73 | } 74 | 75 | showMessageElem(timeout: number) { 76 | this.elem.appendChild(this.message); 77 | 78 | if (this.messageTimer) { 79 | clearTimeout(this.messageTimer); 80 | } 81 | if (timeout > 0) { 82 | this.messageTimer = setTimeout(() => { 83 | try { 84 | this.elem.removeChild(this.message); 85 | } catch (error) { 86 | console.error(error); 87 | } 88 | }, timeout); 89 | } 90 | }; 91 | 92 | removeMessage(): void { 93 | if (this.message.parentNode == this.elem) { 94 | this.elem.removeChild(this.message); 95 | } 96 | } 97 | 98 | setWindowTitle(title: string) { 99 | document.title = title; 100 | }; 101 | 102 | setPreferences(value: object) { 103 | Object.keys(value).forEach((key) => { 104 | if (key == "EnableWebGL" && key) { 105 | this.term.loadAddon(new WebglAddon()); 106 | } else if (key == "font-size") { 107 | this.term.options.fontSize = value[key] 108 | } else if (key == "font-family") { 109 | this.term.options.fontFamily = value[key] 110 | } 111 | }); 112 | }; 113 | 114 | sendInput(data: Uint8Array) { 115 | return this.toServer(data) 116 | } 117 | 118 | onInput(callback: (input: string) => void) { 119 | this.encoder = new TextEncoder() 120 | this.toServer = callback; 121 | 122 | // I *think* we're ok like this, but if not, we can dispose 123 | // of the previous handler and put the new one in place. 124 | if (this.onDataHandler !== undefined) { 125 | return 126 | } 127 | 128 | this.onDataHandler = this.term.onData((input) => { 129 | this.toServer(this.encoder.encode(input)); 130 | }); 131 | }; 132 | 133 | onResize(callback: (colmuns: number, rows: number) => void) { 134 | this.onResizeHandler = this.term.onResize(() => { 135 | callback(this.term.cols, this.term.rows); 136 | }); 137 | }; 138 | 139 | deactivate(): void { 140 | this.onDataHandler.dispose(); 141 | this.onResizeHandler.dispose(); 142 | this.term.blur(); 143 | } 144 | 145 | reset(): void { 146 | this.removeMessage(); 147 | this.term.clear(); 148 | } 149 | 150 | close(): void { 151 | window.removeEventListener("resize", this.resizeListener); 152 | this.term.dispose(); 153 | } 154 | 155 | disableStdin(): void { 156 | this.term.options.disableStdin = true; 157 | } 158 | 159 | enableStdin(): void { 160 | this.term.options.disableStdin = false; 161 | } 162 | 163 | focus(): void { 164 | this.term.focus(); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /js/src/zmodem.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ComponentChildren, createRef, render } from "preact"; 2 | import { ITerminalAddon, Terminal } from "xterm"; 3 | import { Browser, Detection, Offer, Sentry, Session } from 'zmodem.js/src/zmodem_browser'; 4 | import { Button, MyModal } from "./MyModal"; 5 | 6 | export class ZModemAddon implements ITerminalAddon { 7 | term: Terminal; 8 | elem: HTMLDivElement; 9 | sentry: Sentry; 10 | toTerminal: (data: Uint8Array) => void; 11 | toServer: (data: Uint8Array) => void; 12 | 13 | constructor(props: { 14 | toTerminal: (data: Uint8Array) => void, 15 | toServer: (data: Uint8Array) => void 16 | }) { 17 | this.createElement(); 18 | this.toTerminal = props.toTerminal; 19 | this.toServer = props.toServer; 20 | 21 | this.init(); 22 | } 23 | 24 | private createElement() { 25 | this.elem = document.createElement("div"); 26 | document.body.prepend(this.elem); 27 | } 28 | 29 | consume(data: Uint8Array) { 30 | this.sentry.consume(data) 31 | } 32 | 33 | activate(terminal: Terminal): void { 34 | this.term = terminal 35 | } 36 | 37 | dispose() { 38 | } 39 | 40 | private init() { 41 | render(<>, this.elem); 42 | 43 | this.sentry = new Sentry({ 44 | 'to_terminal': (d: Uint8Array) => this.toTerminal(d), 45 | 'on_detect': (detection: Detection) => this.onDetect(detection), 46 | 'sender': (x: Uint8Array) => { this.toServer(x) }, 47 | 'on_retract': () => this.reset(), 48 | }); 49 | } 50 | 51 | private reset() { 52 | this.init(); 53 | this.term.options.disableStdin = false; 54 | this.term.focus(); 55 | } 56 | 57 | private onDetect(detection: Detection) { 58 | var zsession = detection.confirm(); 59 | 60 | this.term.options.disableStdin = true; 61 | 62 | zsession.on('session_end', () => { this.reset() }); 63 | 64 | if (zsession.type === "send") { 65 | this.send(zsession); 66 | } 67 | else { 68 | zsession.on("offer", (xfer: any) => this.onOffer(xfer)); 69 | zsession.start(); 70 | } 71 | } 72 | 73 | private send(zsession: Session) { 74 | render(, this.elem) 75 | } 76 | 77 | private onOffer(xfer: Offer) { 78 | render( this.reset()} />, this.elem) 79 | } 80 | } 81 | 82 | // Renders a bootstrap progress bar 83 | function Progress(props: { min: number, max: number, now: number, children?: ComponentChildren }) { 84 | let { min, max, now } = props; 85 | let percentage = "0"; 86 | 87 | if ((typeof min === "number") && 88 | (typeof max === "number") && 89 | (typeof now === "number") && 90 | (min != max)) { 91 | percentage = (100 * (now - min) / (max - min)).toFixed(0); 92 | } 93 | 94 | return
95 |
{props.children}
96 |
97 | } 98 | 99 | interface ReceiveFileModalProps { 100 | xfer: Offer; 101 | onFinish?: () => void; 102 | } 103 | 104 | interface ReceiveFileModalState { 105 | state: "notstarted" | "started" | "skipped" | "done" 106 | } 107 | 108 | export class ReceiveFileModal extends Component { 109 | constructor(props) { 110 | super(props) 111 | this.setState({ state: "notstarted" }) 112 | } 113 | 114 | accept() { 115 | this.setState({ state: "started" }); 116 | 117 | let timerID = setInterval( 118 | () => this.forceUpdate(), 119 | 100 120 | ); 121 | 122 | this.props.xfer.accept().then((payloads: any) => { 123 | // All done, so stop updating the progress bar 124 | // and perform a final render. 125 | clearInterval(timerID); 126 | this.forceUpdate(); 127 | 128 | if (this.state.state != "skipped") { 129 | Browser.save_to_disk( 130 | payloads, 131 | this.props.xfer.get_details().name 132 | ); 133 | } 134 | this.setState({ state: "done" }) 135 | }) 136 | } 137 | 138 | finish() { 139 | console.log('finished'); 140 | if (this.props.onFinish) this.props.onFinish(); 141 | } 142 | 143 | progress() { 144 | if (this.state.state !== "notstarted") { 145 | return 146 | } 147 | } 148 | 149 | skip() { 150 | this.props.xfer.skip() 151 | this.setState({ state: "skipped" }) 152 | } 153 | 154 | buttons() { 155 | switch (this.state.state) { 156 | case "notstarted": 157 | return <> 158 | 159 | 160 | 161 | case "started": 162 | return <> 163 | 164 | 165 | case "skipped": 166 | return <> 167 | 168 | 169 | } 170 | } 171 | 172 | render() { 173 | if (this.state.state != "done") 174 | return 176 | Accept {this.props.xfer.get_details().name} ({this.props.xfer.get_details().size.toLocaleString(undefined, { maximumFractionDigits: 0 })} bytes)? 177 | {this.progress()} 178 | 179 | } 180 | } 181 | 182 | 183 | export class SendFileModal extends Component { 184 | filePickerRef = createRef(); 185 | 186 | constructor(props: SendFileModalProps) { 187 | super(props) 188 | this.setState({ state: "notstarted" }) 189 | } 190 | 191 | buttons() { 192 | switch (this.state.state) { 193 | case "started": 194 | return <> 195 | 199 | 200 | case "notstarted": 201 | return <> 202 | 203 | 204 | default: 205 | return 206 | } 207 | } 208 | 209 | send() { 210 | Browser.send_files(this.props.session, 211 | this.filePickerRef.current!.files, { 212 | on_offer_response: (f, xfer) => { this.setState({ state: "started" }) }, 213 | }).then(() => { 214 | this.setState({ state: "done" }) 215 | this.props.session.close() 216 | if (this.props.onFinish !== undefined) { 217 | this.props.onFinish(); 218 | } 219 | }) 220 | .catch(e => console.log(e)); 221 | } 222 | 223 | render() { 224 | if (this.state.state != "done") 225 | return 227 |
228 | 231 | 232 |
233 |
234 | } 235 | } 236 | 237 | interface SendFileModalProps { 238 | onFinish?: () => void; 239 | session: Session; 240 | } 241 | 242 | interface SendFileModalState { 243 | state: "notstarted" | "started" | "done" 244 | currentFile: any 245 | } 246 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "preact", 6 | "strictNullChecks": true, 7 | "noUnusedLocals" : true, 8 | "noImplicitThis": true, 9 | "alwaysStrict": true, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "target": "esnext", 13 | "module": "commonJS", 14 | "baseUrl": ".", 15 | "types": ["preact", "node"], 16 | "paths": { 17 | "*": ["./typings/*"] 18 | } 19 | }, 20 | "exclude": [ 21 | "node_modules", 22 | "dist" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TerserPlugin = require("terser-webpack-plugin"); 3 | const LicenseWebpackPlugin = require('license-webpack-plugin').LicenseWebpackPlugin; 4 | 5 | var devtool; 6 | 7 | if (process.env.DEV === '1') { 8 | devtool = 'inline-source-map'; 9 | } else { 10 | devtool = 'source-map'; 11 | } 12 | 13 | module.exports = { 14 | entry: "./src/main.ts", 15 | entry: { 16 | "gotty": "./src/main.ts", 17 | }, 18 | output: { 19 | path: path.resolve(__dirname, '../bindata/static/js/'), 20 | }, 21 | devtool: devtool, 22 | resolve: { 23 | extensions: [".ts", ".tsx", ".js"], 24 | }, 25 | plugins: [ 26 | new LicenseWebpackPlugin() 27 | ], 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.tsx?$/, 32 | loader: "ts-loader", 33 | exclude: /node_modules/ 34 | }, 35 | { 36 | test: /\.css$/i, 37 | use: ["style-loader", "css-loader"], 38 | }, 39 | { 40 | test: /\.scss$/i, 41 | use: ["style-loader", "css-loader", { 42 | loader: "sass-loader", 43 | options: { 44 | sassOptions: { 45 | includePaths: ["node_modules/bootstrap/scss"] 46 | } 47 | } 48 | } 49 | ], 50 | }, 51 | ], 52 | }, 53 | optimization: { 54 | minimize: true, 55 | minimizer: [new TerserPlugin()], 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | "syscall" 12 | 13 | cli "github.com/urfave/cli/v2" 14 | 15 | "github.com/sorenisanerd/gotty/backend/localcommand" 16 | "github.com/sorenisanerd/gotty/pkg/homedir" 17 | "github.com/sorenisanerd/gotty/server" 18 | "github.com/sorenisanerd/gotty/utils" 19 | ) 20 | 21 | func main() { 22 | app := cli.NewApp() 23 | app.Name = "gotty" 24 | app.Version = Version 25 | app.Usage = "Share your terminal as a web application" 26 | app.HideHelpCommand = true 27 | appOptions := &server.Options{} 28 | 29 | if err := utils.ApplyDefaultValues(appOptions); err != nil { 30 | exit(err, 1) 31 | } 32 | backendOptions := &localcommand.Options{} 33 | if err := utils.ApplyDefaultValues(backendOptions); err != nil { 34 | exit(err, 1) 35 | } 36 | 37 | cliFlags, flagMappings, err := utils.GenerateFlags(appOptions, backendOptions) 38 | if err != nil { 39 | exit(err, 3) 40 | } 41 | 42 | app.Flags = append( 43 | cliFlags, 44 | &cli.StringFlag{ 45 | Name: "config", 46 | Value: "~/.gotty", 47 | Usage: "Config file path", 48 | EnvVars: []string{"GOTTY_CONFIG"}, 49 | }, 50 | ) 51 | 52 | app.Action = func(c *cli.Context) error { 53 | if c.NArg() == 0 { 54 | msg := "Error: No command given." 55 | cli.ShowAppHelp(c) 56 | exit(fmt.Errorf(msg), 1) 57 | } 58 | 59 | configFile := c.String("config") 60 | _, err := os.Stat(homedir.Expand(configFile)) 61 | if configFile != "~/.gotty" || !os.IsNotExist(err) { 62 | if err := utils.ApplyConfigFile(configFile, appOptions, backendOptions); err != nil { 63 | exit(err, 2) 64 | } 65 | } 66 | 67 | utils.ApplyFlags(cliFlags, flagMappings, c, appOptions, backendOptions) 68 | 69 | if appOptions.Quiet { 70 | log.SetFlags(0) 71 | log.SetOutput(io.Discard) 72 | } 73 | 74 | if c.IsSet("credential") { 75 | appOptions.EnableBasicAuth = true 76 | } 77 | if c.IsSet("tls-ca-crt") { 78 | appOptions.EnableTLSClientAuth = true 79 | } 80 | 81 | err = appOptions.Validate() 82 | if err != nil { 83 | exit(err, 6) 84 | } 85 | 86 | args := c.Args() 87 | factory, err := localcommand.NewFactory(args.First(), args.Tail(), backendOptions) 88 | if err != nil { 89 | exit(err, 3) 90 | } 91 | 92 | hostname, _ := os.Hostname() 93 | appOptions.TitleVariables = map[string]interface{}{ 94 | "command": args.First(), 95 | "argv": args.Tail(), 96 | "hostname": hostname, 97 | } 98 | 99 | srv, err := server.New(factory, appOptions) 100 | if err != nil { 101 | exit(err, 3) 102 | } 103 | 104 | ctx, cancel := context.WithCancel(context.Background()) 105 | gCtx, gCancel := context.WithCancel(context.Background()) 106 | 107 | log.Printf("GoTTY is starting with command: %s", strings.Join(args.Slice(), " ")) 108 | 109 | errs := make(chan error, 1) 110 | go func() { 111 | errs <- srv.Run(ctx, server.WithGracefullContext(gCtx)) 112 | }() 113 | err = waitSignals(errs, cancel, gCancel) 114 | 115 | if err != nil && err != context.Canceled { 116 | fmt.Printf("Error: %s\n", err) 117 | exit(err, 8) 118 | } 119 | 120 | return nil 121 | } 122 | app.Run(os.Args) 123 | } 124 | 125 | func exit(err error, code int) { 126 | if err != nil { 127 | fmt.Println(err) 128 | } 129 | os.Exit(code) 130 | } 131 | 132 | func waitSignals(errs chan error, cancel context.CancelFunc, gracefullCancel context.CancelFunc) error { 133 | sigChan := make(chan os.Signal, 1) 134 | signal.Notify( 135 | sigChan, 136 | syscall.SIGINT, 137 | syscall.SIGTERM, 138 | ) 139 | 140 | select { 141 | case err := <-errs: 142 | return err 143 | 144 | case s := <-sigChan: 145 | switch s { 146 | case syscall.SIGINT: 147 | gracefullCancel() 148 | fmt.Println("C-C to force close") 149 | select { 150 | case err := <-errs: 151 | return err 152 | case <-sigChan: 153 | fmt.Println("Force closing...") 154 | cancel() 155 | return <-errs 156 | } 157 | default: 158 | cancel() 159 | return <-errs 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /pkg/homedir/expand.go: -------------------------------------------------------------------------------- 1 | package homedir 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func Expand(path string) string { 8 | if path[0:2] == "~/" { 9 | return os.Getenv("HOME") + path[1:] 10 | } else { 11 | return path 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/randomstring/generate.go: -------------------------------------------------------------------------------- 1 | package randomstring 2 | 3 | import ( 4 | "crypto/rand" 5 | "math/big" 6 | "strconv" 7 | ) 8 | 9 | func Generate(length int) string { 10 | const base = 36 11 | size := big.NewInt(base) 12 | n := make([]byte, length) 13 | for i, _ := range n { 14 | c, _ := rand.Int(rand.Reader, size) 15 | n[i] = strconv.FormatInt(c.Int64(), base)[0] 16 | } 17 | return string(n) 18 | } 19 | -------------------------------------------------------------------------------- /release-checklist.md: -------------------------------------------------------------------------------- 1 | * Check that the build works, duh. 2 | * Check that "gotty bash" works and that it does not accept input. 3 | * Check that "gotty -w bash" works and that it DOES accept input. 4 | * Check that "gotty -w bash" works and that it DOES accept input. 5 | * Check that cross-compilation works. 6 | * Ensure NEWS is up-to-date 7 | * Ensure contributor list is up-to-date 8 | * Ensure usage is correct in Makefile 9 | -------------------------------------------------------------------------------- /resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sorenisanerd/gotty/c69d11d17d83a86af4e0ea766d04b026ddb27c1f/resources/favicon.ico -------------------------------------------------------------------------------- /resources/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /resources/icon_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sorenisanerd/gotty/c69d11d17d83a86af4e0ea766d04b026ddb27c1f/resources/icon_192.png -------------------------------------------------------------------------------- /resources/index.css: -------------------------------------------------------------------------------- 1 | html, body, #terminal { 2 | background: black; 3 | height: 100%; 4 | width: 100%; 5 | padding: 0%; 6 | margin: 0%; 7 | } 8 | 9 | .progress .progress-bar { 10 | transition: unset; 11 | transition-duration: 0.1s; 12 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 13 | } -------------------------------------------------------------------------------- /resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ .title }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /resources/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "{{ .title }}", 3 | "name": "{{ .title }}", 4 | "start_url": "./", 5 | "icons": [ 6 | { 7 | "src": "./icon_192.png", 8 | "type": "image/png", 9 | "sizes": "192x192" 10 | } 11 | ], 12 | "display": "minimal-ui", 13 | "theme_color": "#000000", 14 | "background_color": "#000000" 15 | } 16 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sorenisanerd/gotty/c69d11d17d83a86af4e0ea766d04b026ddb27c1f/screenshot.gif -------------------------------------------------------------------------------- /server/handler_atomic.go: -------------------------------------------------------------------------------- 1 | package server 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 | -------------------------------------------------------------------------------- /server/handlers.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "sync/atomic" 13 | 14 | "github.com/gorilla/websocket" 15 | "github.com/pkg/errors" 16 | 17 | "github.com/sorenisanerd/gotty/webtty" 18 | ) 19 | 20 | func (server *Server) generateHandleWS(ctx context.Context, cancel context.CancelFunc, counter *counter) http.HandlerFunc { 21 | once := new(int64) 22 | 23 | go func() { 24 | select { 25 | case <-counter.timer().C: 26 | cancel() 27 | case <-ctx.Done(): 28 | } 29 | }() 30 | 31 | return func(w http.ResponseWriter, r *http.Request) { 32 | if server.options.Once { 33 | success := atomic.CompareAndSwapInt64(once, 0, 1) 34 | if !success { 35 | http.Error(w, "Server is shutting down", http.StatusServiceUnavailable) 36 | return 37 | } 38 | } 39 | 40 | num := counter.add(1) 41 | closeReason := "unknown reason" 42 | 43 | defer func() { 44 | num := counter.done() 45 | log.Printf( 46 | "Connection closed by %s: %s, connections: %d/%d", 47 | closeReason, r.RemoteAddr, num, server.options.MaxConnection, 48 | ) 49 | 50 | if server.options.Once { 51 | cancel() 52 | } 53 | }() 54 | 55 | if int64(server.options.MaxConnection) != 0 { 56 | if num > server.options.MaxConnection { 57 | closeReason = "exceeding max number of connections" 58 | return 59 | } 60 | } 61 | 62 | log.Printf("New client connected: %s, connections: %d/%d", r.RemoteAddr, num, server.options.MaxConnection) 63 | 64 | if r.Method != "GET" { 65 | http.Error(w, "Method not allowed", 405) 66 | return 67 | } 68 | 69 | conn, err := server.upgrader.Upgrade(w, r, nil) 70 | if err != nil { 71 | closeReason = err.Error() 72 | return 73 | } 74 | defer conn.Close() 75 | 76 | if server.options.PassHeaders { 77 | err = server.processWSConn(ctx, conn, r.Header) 78 | } else { 79 | err = server.processWSConn(ctx, conn, nil) 80 | } 81 | 82 | switch err { 83 | case ctx.Err(): 84 | closeReason = "cancelation" 85 | case webtty.ErrSlaveClosed: 86 | closeReason = server.factory.Name() 87 | case webtty.ErrMasterClosed: 88 | closeReason = "client" 89 | default: 90 | closeReason = fmt.Sprintf("an error: %s", err) 91 | } 92 | } 93 | } 94 | 95 | func (server *Server) processWSConn(ctx context.Context, conn *websocket.Conn, headers map[string][]string) error { 96 | typ, initLine, err := conn.ReadMessage() 97 | if err != nil { 98 | return errors.Wrapf(err, "failed to authenticate websocket connection") 99 | } 100 | if typ != websocket.TextMessage { 101 | return errors.New("failed to authenticate websocket connection: invalid message type") 102 | } 103 | 104 | var init InitMessage 105 | err = json.Unmarshal(initLine, &init) 106 | if err != nil { 107 | return errors.Wrapf(err, "failed to authenticate websocket connection") 108 | } 109 | if init.AuthToken != server.options.Credential { 110 | return errors.New("failed to authenticate websocket connection") 111 | } 112 | 113 | queryPath := "?" 114 | if server.options.PermitArguments && init.Arguments != "" { 115 | queryPath = init.Arguments 116 | } 117 | 118 | query, err := url.Parse(queryPath) 119 | if err != nil { 120 | return errors.Wrapf(err, "failed to parse arguments") 121 | } 122 | params := query.Query() 123 | var slave Slave 124 | slave, err = server.factory.New(params, headers) 125 | if err != nil { 126 | return errors.Wrapf(err, "failed to create backend") 127 | } 128 | defer slave.Close() 129 | 130 | titleVars := server.titleVariables( 131 | []string{"server", "master", "slave"}, 132 | map[string]map[string]interface{}{ 133 | "server": server.options.TitleVariables, 134 | "master": map[string]interface{}{ 135 | "remote_addr": conn.RemoteAddr(), 136 | }, 137 | "slave": slave.WindowTitleVariables(), 138 | }, 139 | ) 140 | 141 | titleBuf := new(bytes.Buffer) 142 | err = server.titleTemplate.Execute(titleBuf, titleVars) 143 | if err != nil { 144 | return errors.Wrapf(err, "failed to fill window title template") 145 | } 146 | 147 | opts := []webtty.Option{ 148 | webtty.WithWindowTitle(titleBuf.Bytes()), 149 | } 150 | if server.options.PermitWrite { 151 | opts = append(opts, webtty.WithPermitWrite()) 152 | } 153 | if server.options.EnableReconnect { 154 | opts = append(opts, webtty.WithReconnect(server.options.ReconnectTime)) 155 | } 156 | if server.options.Width > 0 { 157 | opts = append(opts, webtty.WithFixedColumns(server.options.Width)) 158 | } 159 | if server.options.Height > 0 { 160 | opts = append(opts, webtty.WithFixedRows(server.options.Height)) 161 | } 162 | tty, err := webtty.New(&wsWrapper{conn}, slave, opts...) 163 | if err != nil { 164 | return errors.Wrapf(err, "failed to create webtty") 165 | } 166 | 167 | err = tty.Run(ctx) 168 | 169 | return err 170 | } 171 | 172 | func (server *Server) handleIndex(w http.ResponseWriter, r *http.Request) { 173 | indexVars, err := server.indexVariables(r) 174 | if err != nil { 175 | http.Error(w, "Internal Server Error", 500) 176 | return 177 | } 178 | 179 | indexBuf := new(bytes.Buffer) 180 | err = server.indexTemplate.Execute(indexBuf, indexVars) 181 | if err != nil { 182 | http.Error(w, "Internal Server Error", 500) 183 | return 184 | } 185 | 186 | w.Write(indexBuf.Bytes()) 187 | } 188 | 189 | func (server *Server) handleManifest(w http.ResponseWriter, r *http.Request) { 190 | indexVars, err := server.indexVariables(r) 191 | if err != nil { 192 | http.Error(w, "Internal Server Error", 500) 193 | return 194 | } 195 | 196 | indexBuf := new(bytes.Buffer) 197 | err = server.manifestTemplate.Execute(indexBuf, indexVars) 198 | if err != nil { 199 | http.Error(w, "Internal Server Error", 500) 200 | return 201 | } 202 | 203 | w.Write(indexBuf.Bytes()) 204 | } 205 | 206 | func (server *Server) indexVariables(r *http.Request) (map[string]interface{}, error) { 207 | titleVars := server.titleVariables( 208 | []string{"server", "master"}, 209 | map[string]map[string]interface{}{ 210 | "server": server.options.TitleVariables, 211 | "master": map[string]interface{}{ 212 | "remote_addr": r.RemoteAddr, 213 | }, 214 | }, 215 | ) 216 | 217 | titleBuf := new(bytes.Buffer) 218 | err := server.titleTemplate.Execute(titleBuf, titleVars) 219 | if err != nil { 220 | return nil, err 221 | } 222 | 223 | indexVars := map[string]interface{}{ 224 | "title": titleBuf.String(), 225 | } 226 | return indexVars, err 227 | } 228 | 229 | func (server *Server) handleAuthToken(w http.ResponseWriter, r *http.Request) { 230 | w.Header().Set("Content-Type", "application/javascript") 231 | // @TODO hashing? 232 | w.Write([]byte("var gotty_auth_token = '" + server.options.Credential + "';")) 233 | } 234 | 235 | func (server *Server) handleConfig(w http.ResponseWriter, r *http.Request) { 236 | w.Header().Set("Content-Type", "application/javascript") 237 | lines := []string{ 238 | "var gotty_term = 'xterm';", 239 | "var gotty_ws_query_args = '" + server.options.WSQueryArgs + "';", 240 | } 241 | 242 | w.Write([]byte(strings.Join(lines, "\n"))) 243 | } 244 | 245 | // titleVariables merges maps in a specified order. 246 | // varUnits are name-keyed maps, whose names will be iterated using order. 247 | func (server *Server) titleVariables(order []string, varUnits map[string]map[string]interface{}) map[string]interface{} { 248 | titleVars := map[string]interface{}{} 249 | 250 | for _, name := range order { 251 | vars, ok := varUnits[name] 252 | if !ok { 253 | panic("title variable name error") 254 | } 255 | for key, val := range vars { 256 | titleVars[key] = val 257 | } 258 | } 259 | 260 | // safe net for conflicted keys 261 | for _, name := range order { 262 | titleVars[name] = varUnits[name] 263 | } 264 | 265 | return titleVars 266 | } 267 | -------------------------------------------------------------------------------- /server/init_message.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | type InitMessage struct { 4 | Arguments string `json:"Arguments,omitempty"` 5 | AuthToken string `json:"AuthToken,omitempty"` 6 | } 7 | -------------------------------------------------------------------------------- /server/list_address.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net" 5 | ) 6 | 7 | func listAddresses() (addresses []string) { 8 | ifaces, err := net.Interfaces() 9 | if err != nil { 10 | return []string{} 11 | } 12 | 13 | addresses = make([]string, 0, len(ifaces)) 14 | 15 | for _, iface := range ifaces { 16 | ifAddrs, _ := iface.Addrs() 17 | for _, ifAddr := range ifAddrs { 18 | switch v := ifAddr.(type) { 19 | case *net.IPNet: 20 | addresses = append(addresses, v.IP.String()) 21 | case *net.IPAddr: 22 | addresses = append(addresses, v.IP.String()) 23 | } 24 | } 25 | } 26 | 27 | return addresses 28 | } 29 | -------------------------------------------------------------------------------- /server/log_response_writer.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bufio" 5 | "net" 6 | "net/http" 7 | ) 8 | 9 | type logResponseWriter struct { 10 | http.ResponseWriter 11 | status int 12 | } 13 | 14 | func (w *logResponseWriter) WriteHeader(status int) { 15 | w.status = status 16 | w.ResponseWriter.WriteHeader(status) 17 | } 18 | 19 | func (w *logResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 20 | hj, _ := w.ResponseWriter.(http.Hijacker) 21 | w.status = http.StatusSwitchingProtocols 22 | return hj.Hijack() 23 | } 24 | -------------------------------------------------------------------------------- /server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/base64" 5 | "log" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | func (server *Server) wrapLogger(handler http.Handler) http.Handler { 11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | rw := &logResponseWriter{w, 200} 13 | handler.ServeHTTP(rw, r) 14 | log.Printf("%s %d %s %s", r.RemoteAddr, rw.status, r.Method, r.URL.Path) 15 | }) 16 | } 17 | 18 | func (server *Server) wrapHeaders(handler http.Handler) http.Handler { 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | // todo add version 21 | w.Header().Set("Server", "GoTTY") 22 | handler.ServeHTTP(w, r) 23 | }) 24 | } 25 | 26 | func (server *Server) wrapBasicAuth(handler http.Handler, credential string) http.Handler { 27 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | token := strings.SplitN(r.Header.Get("Authorization"), " ", 2) 29 | 30 | if len(token) != 2 || strings.ToLower(token[0]) != "basic" { 31 | w.Header().Set("WWW-Authenticate", `Basic realm="GoTTY"`) 32 | http.Error(w, "Bad Request", http.StatusUnauthorized) 33 | return 34 | } 35 | 36 | payload, err := base64.StdEncoding.DecodeString(token[1]) 37 | if err != nil { 38 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 39 | return 40 | } 41 | 42 | if credential != string(payload) { 43 | w.Header().Set("WWW-Authenticate", `Basic realm="GoTTY"`) 44 | http.Error(w, "authorization failed", http.StatusUnauthorized) 45 | return 46 | } 47 | 48 | log.Printf("Basic Authentication Succeeded: %s", r.RemoteAddr) 49 | handler.ServeHTTP(w, r) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /server/options.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | ) 6 | 7 | type Options struct { 8 | Address string `hcl:"address" flagName:"address" flagSName:"a" flagDescribe:"IP address to listen" default:"0.0.0.0"` 9 | Port string `hcl:"port" flagName:"port" flagSName:"p" flagDescribe:"Port number to liten" default:"8080"` 10 | Path string `hcl:"path" flagName:"path" flagSName:"m" flagDescribe:"Base path" default:"/"` 11 | PermitWrite bool `hcl:"permit_write" flagName:"permit-write" flagSName:"w" flagDescribe:"Permit clients to write to the TTY (BE CAREFUL)" default:"false"` 12 | EnableBasicAuth bool `hcl:"enable_basic_auth" default:"false"` 13 | Credential string `hcl:"credential" flagName:"credential" flagSName:"c" flagDescribe:"Credential for Basic Authentication (ex: user:pass, default disabled)" default:""` 14 | EnableRandomUrl bool `hcl:"enable_random_url" flagName:"random-url" flagSName:"r" flagDescribe:"Add a random string to the URL" default:"false"` 15 | RandomUrlLength int `hcl:"random_url_length" flagName:"random-url-length" flagDescribe:"Random URL length" default:"8"` 16 | EnableTLS bool `hcl:"enable_tls" flagName:"tls" flagSName:"t" flagDescribe:"Enable TLS/SSL" default:"false"` 17 | TLSCrtFile string `hcl:"tls_crt_file" flagName:"tls-crt" flagDescribe:"TLS/SSL certificate file path" default:"~/.gotty.crt"` 18 | TLSKeyFile string `hcl:"tls_key_file" flagName:"tls-key" flagDescribe:"TLS/SSL key file path" default:"~/.gotty.key"` 19 | EnableTLSClientAuth bool `hcl:"enable_tls_client_auth" default:"false"` 20 | TLSCACrtFile string `hcl:"tls_ca_crt_file" flagName:"tls-ca-crt" flagDescribe:"TLS/SSL CA certificate file for client certifications" default:"~/.gotty.ca.crt"` 21 | IndexFile string `hcl:"index_file" flagName:"index" flagDescribe:"Custom index.html file" default:""` 22 | TitleFormat string `hcl:"title_format" flagName:"title-format" flagSName:"" flagDescribe:"Title format of browser window" default:"{{ .command }}@{{ .hostname }}"` 23 | EnableReconnect bool `hcl:"enable_reconnect" flagName:"reconnect" flagDescribe:"Enable reconnection" default:"false"` 24 | ReconnectTime int `hcl:"reconnect_time" flagName:"reconnect-time" flagDescribe:"Time to reconnect" default:"10"` 25 | MaxConnection int `hcl:"max_connection" flagName:"max-connection" flagDescribe:"Maximum connection to gotty" default:"0"` 26 | Once bool `hcl:"once" flagName:"once" flagDescribe:"Accept only one client and exit on disconnection" default:"false"` 27 | Timeout int `hcl:"timeout" flagName:"timeout" flagDescribe:"Timeout seconds for waiting a client(0 to disable)" default:"0"` 28 | PermitArguments bool `hcl:"permit_arguments" flagName:"permit-arguments" flagDescribe:"Permit clients to send command line arguments in URL (e.g. http://example.com:8080/?arg=AAA&arg=BBB)" default:"false"` 29 | PassHeaders bool `hcl:"pass_headers" flagName:"pass-headers" flagDescribe:"Pass HTTP request headers as environment variables (e.g. Cookie becomes HTTP_COOKIE)" default:"false"` 30 | Width int `hcl:"width" flagName:"width" flagDescribe:"Static width of the screen, 0(default) means dynamically resize" default:"0"` 31 | Height int `hcl:"height" flagName:"height" flagDescribe:"Static height of the screen, 0(default) means dynamically resize" default:"0"` 32 | WSOrigin string `hcl:"ws_origin" flagName:"ws-origin" flagDescribe:"A regular expression that matches origin URLs to be accepted by WebSocket. No cross origin requests are acceptable by default" default:""` 33 | WSQueryArgs string `hcl:"ws_query_args" flagName:"ws-query-args" flagDescribe:"Querystring arguments to append to the websocket instantiation" default:""` 34 | EnableWebGL bool `hcl:"enable_webgl" flagName:"enable-webgl" flagDescribe:"Enable WebGL renderer" default:"true"` 35 | Quiet bool `hcl:"quiet" flagName:"quiet" flagDescribe:"Don't log" default:"false"` 36 | 37 | TitleVariables map[string]interface{} 38 | } 39 | 40 | func (options *Options) Validate() error { 41 | if options.EnableTLSClientAuth && !options.EnableTLS { 42 | return errors.New("TLS client authentication is enabled, but TLS is not enabled") 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /server/run_option.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // RunOptions holds a set of configurations for Server.Run(). 8 | type RunOptions struct { 9 | gracefullCtx 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.gracefullCtx = ctx 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "html/template" 8 | "io/fs" 9 | "log" 10 | "net" 11 | "net/http" 12 | "os" 13 | "regexp" 14 | "strings" 15 | noesctmpl "text/template" 16 | "time" 17 | 18 | "github.com/NYTimes/gziphandler" 19 | "github.com/gorilla/websocket" 20 | "github.com/pkg/errors" 21 | 22 | "github.com/sorenisanerd/gotty/bindata" 23 | "github.com/sorenisanerd/gotty/pkg/homedir" 24 | "github.com/sorenisanerd/gotty/pkg/randomstring" 25 | "github.com/sorenisanerd/gotty/webtty" 26 | ) 27 | 28 | // Server provides a webtty HTTP endpoint. 29 | type Server struct { 30 | factory Factory 31 | options *Options 32 | 33 | upgrader *websocket.Upgrader 34 | indexTemplate *template.Template 35 | titleTemplate *noesctmpl.Template 36 | manifestTemplate *template.Template 37 | } 38 | 39 | // New creates a new instance of Server. 40 | // Server will use the New() of the factory provided to handle each request. 41 | func New(factory Factory, options *Options) (*Server, error) { 42 | indexData, err := bindata.Fs.ReadFile("static/index.html") 43 | if err != nil { 44 | panic("index not found") // must be in bindata 45 | } 46 | if options.IndexFile != "" { 47 | path := homedir.Expand(options.IndexFile) 48 | indexData, err = os.ReadFile(path) 49 | if err != nil { 50 | return nil, errors.Wrapf(err, "failed to read custom index file at `%s`", path) 51 | } 52 | } 53 | indexTemplate, err := template.New("index").Parse(string(indexData)) 54 | if err != nil { 55 | panic("index template parse failed") // must be valid 56 | } 57 | 58 | manifestData, err := bindata.Fs.ReadFile("static/manifest.json") 59 | if err != nil { 60 | panic("manifest not found") // must be in bindata 61 | } 62 | manifestTemplate, err := template.New("manifest").Parse(string(manifestData)) 63 | if err != nil { 64 | panic("manifest template parse failed") // must be valid 65 | } 66 | 67 | titleTemplate, err := noesctmpl.New("title").Parse(options.TitleFormat) 68 | if err != nil { 69 | return nil, errors.Wrapf(err, "failed to parse window title format `%s`", options.TitleFormat) 70 | } 71 | 72 | var originChekcer func(r *http.Request) bool 73 | if options.WSOrigin != "" { 74 | matcher, err := regexp.Compile(options.WSOrigin) 75 | if err != nil { 76 | return nil, errors.Wrapf(err, "failed to compile regular expression of Websocket Origin: %s", options.WSOrigin) 77 | } 78 | originChekcer = func(r *http.Request) bool { 79 | return matcher.MatchString(r.Header.Get("Origin")) 80 | } 81 | } 82 | 83 | return &Server{ 84 | factory: factory, 85 | options: options, 86 | 87 | upgrader: &websocket.Upgrader{ 88 | ReadBufferSize: 1024, 89 | WriteBufferSize: 1024, 90 | Subprotocols: webtty.Protocols, 91 | CheckOrigin: originChekcer, 92 | }, 93 | indexTemplate: indexTemplate, 94 | titleTemplate: titleTemplate, 95 | manifestTemplate: manifestTemplate, 96 | }, nil 97 | } 98 | 99 | // Run starts the main process of the Server. 100 | // The cancelation of ctx will shutdown the server immediately with aborting 101 | // existing connections. Use WithGracefullContext() to support gracefull shutdown. 102 | func (server *Server) Run(ctx context.Context, options ...RunOption) error { 103 | cctx, cancel := context.WithCancel(ctx) 104 | opts := &RunOptions{gracefullCtx: context.Background()} 105 | for _, opt := range options { 106 | opt(opts) 107 | } 108 | 109 | counter := newCounter(time.Duration(server.options.Timeout) * time.Second) 110 | 111 | path := server.options.Path 112 | if server.options.EnableRandomUrl { 113 | path = "/" + randomstring.Generate(server.options.RandomUrlLength) + "/" 114 | } 115 | if !strings.HasPrefix(path, "/") { 116 | path = "/" + path 117 | } 118 | if !strings.HasSuffix(path, "/") { 119 | path = path + "/" 120 | } 121 | handlers := server.setupHandlers(cctx, cancel, path, counter) 122 | srv, err := server.setupHTTPServer(handlers) 123 | if err != nil { 124 | return errors.Wrapf(err, "failed to setup an HTTP server") 125 | } 126 | 127 | if server.options.PermitWrite { 128 | log.Printf("Permitting clients to write input to the PTY.") 129 | } 130 | if server.options.Once { 131 | log.Printf("Once option is provided, accepting only one client") 132 | } 133 | 134 | if server.options.Port == "0" { 135 | log.Printf("Port number configured to `0`, choosing a random port") 136 | } 137 | hostPort := net.JoinHostPort(server.options.Address, server.options.Port) 138 | listener, err := net.Listen("tcp", hostPort) 139 | if err != nil { 140 | return errors.Wrapf(err, "failed to listen at `%s`", hostPort) 141 | } 142 | 143 | scheme := "http" 144 | if server.options.EnableTLS { 145 | scheme = "https" 146 | } 147 | host, port, _ := net.SplitHostPort(listener.Addr().String()) 148 | log.Printf("HTTP server is listening at: %s", scheme+"://"+net.JoinHostPort(host, port)+path) 149 | if server.options.Address == "0.0.0.0" { 150 | for _, address := range listAddresses() { 151 | log.Printf("Alternative URL: %s", scheme+"://"+net.JoinHostPort(address, port)+path) 152 | } 153 | } 154 | 155 | srvErr := make(chan error, 1) 156 | go func() { 157 | if server.options.EnableTLS { 158 | crtFile := homedir.Expand(server.options.TLSCrtFile) 159 | keyFile := homedir.Expand(server.options.TLSKeyFile) 160 | log.Printf("TLS crt file: " + crtFile) 161 | log.Printf("TLS key file: " + keyFile) 162 | 163 | err = srv.ServeTLS(listener, crtFile, keyFile) 164 | } else { 165 | err = srv.Serve(listener) 166 | } 167 | if err != nil { 168 | srvErr <- err 169 | } 170 | }() 171 | 172 | go func() { 173 | select { 174 | case <-opts.gracefullCtx.Done(): 175 | srv.Shutdown(context.Background()) 176 | case <-cctx.Done(): 177 | } 178 | }() 179 | 180 | select { 181 | case err = <-srvErr: 182 | if err == http.ErrServerClosed { // by gracefull ctx 183 | err = nil 184 | } else { 185 | cancel() 186 | } 187 | case <-cctx.Done(): 188 | srv.Close() 189 | err = cctx.Err() 190 | } 191 | 192 | conn := counter.count() 193 | if conn > 0 { 194 | log.Printf("Waiting for %d connections to be closed", conn) 195 | } 196 | counter.wait() 197 | 198 | return err 199 | } 200 | 201 | func (server *Server) setupHandlers(ctx context.Context, cancel context.CancelFunc, pathPrefix string, counter *counter) http.Handler { 202 | fs, err := fs.Sub(bindata.Fs, "static") 203 | if err != nil { 204 | log.Fatalf("failed to open static/ subdirectory of embedded filesystem: %v", err) 205 | } 206 | staticFileHandler := http.FileServer(http.FS(fs)) 207 | 208 | var siteMux = http.NewServeMux() 209 | siteMux.HandleFunc(pathPrefix, server.handleIndex) 210 | siteMux.Handle(pathPrefix+"js/", http.StripPrefix(pathPrefix, staticFileHandler)) 211 | siteMux.Handle(pathPrefix+"favicon.ico", http.StripPrefix(pathPrefix, staticFileHandler)) 212 | siteMux.Handle(pathPrefix+"icon.svg", http.StripPrefix(pathPrefix, staticFileHandler)) 213 | siteMux.Handle(pathPrefix+"css/", http.StripPrefix(pathPrefix, staticFileHandler)) 214 | siteMux.Handle(pathPrefix+"icon_192.png", http.StripPrefix(pathPrefix, staticFileHandler)) 215 | 216 | siteMux.HandleFunc(pathPrefix+"manifest.json", server.handleManifest) 217 | siteMux.HandleFunc(pathPrefix+"auth_token.js", server.handleAuthToken) 218 | siteMux.HandleFunc(pathPrefix+"config.js", server.handleConfig) 219 | 220 | siteHandler := http.Handler(siteMux) 221 | 222 | if server.options.EnableBasicAuth { 223 | log.Printf("Using Basic Authentication") 224 | siteHandler = server.wrapBasicAuth(siteHandler, server.options.Credential) 225 | } 226 | 227 | withGz := gziphandler.GzipHandler(server.wrapHeaders(siteHandler)) 228 | siteHandler = server.wrapLogger(withGz) 229 | 230 | wsMux := http.NewServeMux() 231 | wsMux.Handle("/", siteHandler) 232 | wsMux.HandleFunc(pathPrefix+"ws", server.generateHandleWS(ctx, cancel, counter)) 233 | siteHandler = http.Handler(wsMux) 234 | 235 | return siteHandler 236 | } 237 | 238 | func (server *Server) setupHTTPServer(handler http.Handler) (*http.Server, error) { 239 | srv := &http.Server{ 240 | Handler: handler, 241 | } 242 | 243 | if server.options.EnableTLSClientAuth { 244 | tlsConfig, err := server.tlsConfig() 245 | if err != nil { 246 | return nil, errors.Wrapf(err, "failed to setup TLS configuration") 247 | } 248 | srv.TLSConfig = tlsConfig 249 | } 250 | 251 | return srv, nil 252 | } 253 | 254 | func (server *Server) tlsConfig() (*tls.Config, error) { 255 | caFile := homedir.Expand(server.options.TLSCACrtFile) 256 | caCert, err := os.ReadFile(caFile) 257 | if err != nil { 258 | return nil, errors.New("could not open CA crt file " + caFile) 259 | } 260 | caCertPool := x509.NewCertPool() 261 | if !caCertPool.AppendCertsFromPEM(caCert) { 262 | return nil, errors.New("could not parse CA crt file data in " + caFile) 263 | } 264 | tlsConfig := &tls.Config{ 265 | ClientCAs: caCertPool, 266 | ClientAuth: tls.RequireAndVerifyClientCert, 267 | } 268 | return tlsConfig, nil 269 | } 270 | -------------------------------------------------------------------------------- /server/slave.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/sorenisanerd/gotty/webtty" 5 | ) 6 | 7 | // Slave is webtty.Slave with some additional methods. 8 | type Slave interface { 9 | webtty.Slave 10 | 11 | Close() error 12 | } 13 | 14 | type Factory interface { 15 | Name() string 16 | New(params map[string][]string, headers map[string][]string) (Slave, error) 17 | } 18 | -------------------------------------------------------------------------------- /server/ws_wrapper.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/gorilla/websocket" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type wsWrapper struct { 11 | *websocket.Conn 12 | } 13 | 14 | func (wsw *wsWrapper) Write(p []byte) (n int, err error) { 15 | writer, err := wsw.Conn.NextWriter(websocket.TextMessage) 16 | if err != nil { 17 | return 0, err 18 | } 19 | defer writer.Close() 20 | return writer.Write(p) 21 | } 22 | 23 | func (wsw *wsWrapper) Read(p []byte) (n int, err error) { 24 | for { 25 | msgType, reader, err := wsw.Conn.NextReader() 26 | if err != nil { 27 | return 0, err 28 | } 29 | 30 | if msgType != websocket.TextMessage { 31 | continue 32 | } 33 | 34 | b, err := io.ReadAll(reader) 35 | if len(b) > len(p) { 36 | return 0, errors.Wrapf(err, "Client message exceeded buffer size") 37 | } 38 | n = copy(p, b) 39 | return n, err 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /utils/default.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "github.com/fatih/structs" 6 | "reflect" 7 | "strconv" 8 | ) 9 | 10 | func ApplyDefaultValues(struct_ interface{}) (err error) { 11 | o := structs.New(struct_) 12 | 13 | for _, field := range o.Fields() { 14 | defaultValue := field.Tag("default") 15 | if defaultValue == "" { 16 | continue 17 | } 18 | var val interface{} 19 | switch field.Kind() { 20 | case reflect.String: 21 | val = defaultValue 22 | case reflect.Bool: 23 | if defaultValue == "true" { 24 | val = true 25 | } else if defaultValue == "false" { 26 | val = false 27 | } else { 28 | return fmt.Errorf("invalid bool expression: %v, use true/false", defaultValue) 29 | } 30 | case reflect.Int: 31 | val, err = strconv.Atoi(defaultValue) 32 | if err != nil { 33 | return err 34 | } 35 | default: 36 | val = field.Value() 37 | } 38 | field.Set(val) 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /utils/flags.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/fatih/structs" 10 | "github.com/urfave/cli/v2" 11 | "github.com/yudai/hcl" 12 | 13 | "github.com/sorenisanerd/gotty/pkg/homedir" 14 | ) 15 | 16 | func GenerateFlags(options ...interface{}) (flags []cli.Flag, mappings map[string]string, err error) { 17 | mappings = make(map[string]string) 18 | 19 | for _, struct_ := range options { 20 | o := structs.New(struct_) 21 | for _, field := range o.Fields() { 22 | flagName := field.Tag("flagName") 23 | if flagName == "" { 24 | continue 25 | } 26 | envName := "GOTTY_" + strings.ToUpper(strings.Join(strings.Split(flagName, "-"), "_")) 27 | mappings[flagName] = field.Name() 28 | 29 | flagShortName := field.Tag("flagSName") 30 | var aliases []string 31 | if flagShortName != "" { 32 | aliases = []string{flagShortName} 33 | } 34 | 35 | flagDescription := field.Tag("flagDescribe") 36 | 37 | switch field.Kind() { 38 | case reflect.String: 39 | flags = append(flags, &cli.StringFlag{ 40 | Name: flagName, 41 | Value: field.Value().(string), 42 | Usage: flagDescription, 43 | EnvVars: []string{envName}, 44 | Aliases: aliases, 45 | }) 46 | case reflect.Bool: 47 | flags = append(flags, &cli.BoolFlag{ 48 | Name: flagName, 49 | Usage: flagDescription, 50 | EnvVars: []string{envName}, 51 | Aliases: aliases, 52 | DefaultText: field.Tag("default"), 53 | }) 54 | case reflect.Int: 55 | flags = append(flags, &cli.IntFlag{ 56 | Name: flagName, 57 | Value: field.Value().(int), 58 | Usage: flagDescription, 59 | EnvVars: []string{envName}, 60 | Aliases: aliases, 61 | }) 62 | } 63 | } 64 | } 65 | 66 | return 67 | } 68 | 69 | func ApplyFlags( 70 | flags []cli.Flag, 71 | mappingHint map[string]string, 72 | c *cli.Context, 73 | options ...interface{}, 74 | ) { 75 | objects := make([]*structs.Struct, len(options)) 76 | for i, struct_ := range options { 77 | objects[i] = structs.New(struct_) 78 | } 79 | 80 | for flagName, fieldName := range mappingHint { 81 | if !c.IsSet(flagName) { 82 | continue 83 | } 84 | var field *structs.Field 85 | var ok bool 86 | for _, o := range objects { 87 | field, ok = o.FieldOk(fieldName) 88 | if ok { 89 | break 90 | } 91 | } 92 | if field == nil { 93 | continue 94 | } 95 | var val interface{} 96 | switch field.Kind() { 97 | case reflect.String: 98 | val = c.String(flagName) 99 | case reflect.Bool: 100 | val = c.Bool(flagName) 101 | case reflect.Int: 102 | val = c.Int(flagName) 103 | } 104 | field.Set(val) 105 | } 106 | } 107 | 108 | func ApplyConfigFile(filePath string, options ...interface{}) error { 109 | filePath = homedir.Expand(filePath) 110 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 111 | return err 112 | } 113 | 114 | fileString := []byte{} 115 | log.Printf("Loading config file at: %s", filePath) 116 | fileString, err := os.ReadFile(filePath) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | for _, object := range options { 122 | if err := hcl.Decode(object, string(fileString)); err != nil { 123 | return err 124 | } 125 | } 126 | 127 | return nil 128 | } 129 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var Version = "unknown_version" 4 | -------------------------------------------------------------------------------- /webtty/codecs.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | type Decoder interface { 4 | Decode(dst, src []byte) (int, error) 5 | } 6 | 7 | type Encoder interface { 8 | Encode(dst, src []byte) (int, error) 9 | } 10 | 11 | type NullCodec struct{} 12 | 13 | func (NullCodec) Encode(dst, src []byte) (int, error) { 14 | return copy(dst, src), nil 15 | } 16 | 17 | func (NullCodec) Decode(dst, src []byte) (int, error) { 18 | return copy(dst, src), nil 19 | } 20 | -------------------------------------------------------------------------------- /webtty/doc.go: -------------------------------------------------------------------------------- 1 | // Package webtty provides a protocl and an implementation to 2 | // controll terminals thorough networks. 3 | package webtty 4 | -------------------------------------------------------------------------------- /webtty/errors.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrSlaveClosed indicates the function has exited by the slave 9 | ErrSlaveClosed = errors.New("slave closed") 10 | 11 | // ErrSlaveClosed is returned when the slave connection is closed. 12 | ErrMasterClosed = errors.New("master closed") 13 | ) 14 | -------------------------------------------------------------------------------- /webtty/master.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // Master represents a PTY master, usually it's a websocket connection. 8 | type Master io.ReadWriter 9 | -------------------------------------------------------------------------------- /webtty/message_types.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | // Protocols defines the name of this protocol, 4 | // which is supposed to be used to the subprotocol of Websockt streams. 5 | var Protocols = []string{"webtty"} 6 | 7 | const ( 8 | // Unknown message type, maybe sent by a bug 9 | UnknownInput = '0' 10 | // User input typically from a keyboard 11 | Input = '1' 12 | // Ping to the server 13 | Ping = '2' 14 | // Notify that the browser size has been changed 15 | ResizeTerminal = '3' 16 | // Change encoding 17 | SetEncoding = '4' 18 | ) 19 | 20 | const ( 21 | // Unknown message type, maybe set by a bug 22 | UnknownOutput = '0' 23 | // Normal output to the terminal 24 | Output = '1' 25 | // Pong to the browser 26 | Pong = '2' 27 | // Set window title of the terminal 28 | SetWindowTitle = '3' 29 | // Set terminal preference 30 | SetPreferences = '4' 31 | // Make terminal to reconnect 32 | SetReconnect = '5' 33 | // Set the input buffer size 34 | SetBufferSize = '6' 35 | ) 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webtty/slave.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // Slave represents a PTY slave, typically it's a local command. 8 | type Slave interface { 9 | io.ReadWriter 10 | 11 | // WindowTitleVariables returns any values that can be used to fill out 12 | // the title of a terminal. 13 | WindowTitleVariables() map[string]interface{} 14 | 15 | // ResizeTerminal sets a new size of the terminal. 16 | ResizeTerminal(columns int, rows int) error 17 | } 18 | -------------------------------------------------------------------------------- /webtty/webtty.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "sync" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // WebTTY bridges a PTY slave and its PTY master. 13 | // To support text-based streams and side channel commands such as 14 | // terminal resizing, WebTTY uses an original protocol. 15 | type WebTTY struct { 16 | // PTY Master, which probably a connection to browser 17 | masterConn Master 18 | // PTY Slave 19 | slave Slave 20 | 21 | windowTitle []byte 22 | permitWrite bool 23 | columns int 24 | rows int 25 | reconnect int // in seconds 26 | masterPrefs []byte 27 | decoder Decoder 28 | 29 | bufferSize int 30 | writeMutex sync.Mutex 31 | } 32 | 33 | // New creates a new instance of WebTTY. 34 | // masterConn is a connection to the PTY master, 35 | // typically it's a websocket connection to a client. 36 | // slave is a PTY slave such as a local command with a PTY. 37 | func New(masterConn Master, slave Slave, options ...Option) (*WebTTY, error) { 38 | wt := &WebTTY{ 39 | masterConn: masterConn, 40 | slave: slave, 41 | 42 | permitWrite: false, 43 | columns: 0, 44 | rows: 0, 45 | 46 | bufferSize: 1024, 47 | decoder: &NullCodec{}, 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 | //base64 length 76 | effectiveBufferSize := wt.bufferSize - 1 77 | //max raw data length 78 | maxChunkSize := int(effectiveBufferSize/4) * 3 79 | 80 | n, err := wt.slave.Read(buffer[:maxChunkSize]) 81 | if err != nil { 82 | return ErrSlaveClosed 83 | } 84 | 85 | err = wt.handleSlaveReadEvent(buffer[:n]) 86 | if err != nil { 87 | return err 88 | } 89 | } 90 | }() 91 | }() 92 | 93 | go func() { 94 | errs <- func() error { 95 | buffer := make([]byte, wt.bufferSize) 96 | for { 97 | n, err := wt.masterConn.Read(buffer) 98 | if err != nil { 99 | return ErrMasterClosed 100 | } 101 | 102 | err = wt.handleMasterReadEvent(buffer[:n]) 103 | if err != nil { 104 | return err 105 | } 106 | } 107 | }() 108 | }() 109 | 110 | select { 111 | case <-ctx.Done(): 112 | err = ctx.Err() 113 | case err = <-errs: 114 | } 115 | 116 | return err 117 | } 118 | 119 | func (wt *WebTTY) sendInitializeMessage() error { 120 | err := wt.masterWrite(append([]byte{SetWindowTitle}, wt.windowTitle...)) 121 | if err != nil { 122 | return errors.Wrapf(err, "failed to send window title") 123 | } 124 | 125 | bufSizeMsg, _ := json.Marshal(wt.bufferSize) 126 | err = wt.masterWrite(append([]byte{SetBufferSize}, bufSizeMsg...)) 127 | if err != nil { 128 | return errors.Wrapf(err, "failed to send buffer size") 129 | } 130 | 131 | if wt.reconnect > 0 { 132 | reconnect, _ := json.Marshal(wt.reconnect) 133 | err := wt.masterWrite(append([]byte{SetReconnect}, reconnect...)) 134 | if err != nil { 135 | return errors.Wrapf(err, "failed to set reconnect") 136 | } 137 | } 138 | 139 | if wt.masterPrefs != nil { 140 | err := wt.masterWrite(append([]byte{SetPreferences}, wt.masterPrefs...)) 141 | if err != nil { 142 | return errors.Wrapf(err, "failed to set preferences") 143 | } 144 | } 145 | 146 | return nil 147 | } 148 | 149 | func (wt *WebTTY) handleSlaveReadEvent(data []byte) error { 150 | safeMessage := base64.StdEncoding.EncodeToString(data) 151 | err := wt.masterWrite(append([]byte{Output}, []byte(safeMessage)...)) 152 | if err != nil { 153 | return errors.Wrapf(err, "failed to send message to master") 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (wt *WebTTY) masterWrite(data []byte) error { 160 | wt.writeMutex.Lock() 161 | defer wt.writeMutex.Unlock() 162 | 163 | _, err := wt.masterConn.Write(data) 164 | if err != nil { 165 | return errors.Wrapf(err, "failed to write to master") 166 | } 167 | 168 | return nil 169 | } 170 | 171 | func (wt *WebTTY) handleMasterReadEvent(data []byte) error { 172 | if len(data) == 0 { 173 | return errors.New("unexpected zero length read from master") 174 | } 175 | 176 | switch data[0] { 177 | case Input: 178 | if !wt.permitWrite { 179 | return nil 180 | } 181 | 182 | if len(data) <= 1 { 183 | return nil 184 | } 185 | 186 | var decodedBuffer = make([]byte, len(data)) 187 | n, err := wt.decoder.Decode(decodedBuffer, data[1:]) 188 | if err != nil { 189 | return errors.Wrapf(err, "failed to decode received data") 190 | } 191 | 192 | _, err = wt.slave.Write(decodedBuffer[:n]) 193 | if err != nil { 194 | return errors.Wrapf(err, "failed to write received data to slave") 195 | } 196 | 197 | case Ping: 198 | err := wt.masterWrite([]byte{Pong}) 199 | if err != nil { 200 | return errors.Wrapf(err, "failed to return Pong message to master") 201 | } 202 | 203 | case SetEncoding: 204 | switch string(data[1:]) { 205 | case "base64": 206 | wt.decoder = base64.StdEncoding 207 | case "null": 208 | wt.decoder = NullCodec{} 209 | } 210 | 211 | case ResizeTerminal: 212 | if wt.columns != 0 && wt.rows != 0 { 213 | break 214 | } 215 | 216 | if len(data) <= 1 { 217 | return errors.New("received malformed remote command for terminal resize: empty payload") 218 | } 219 | 220 | var args argResizeTerminal 221 | err := json.Unmarshal(data[1:], &args) 222 | if err != nil { 223 | return errors.Wrapf(err, "received malformed data for terminal resize") 224 | } 225 | rows := wt.rows 226 | if rows == 0 { 227 | rows = int(args.Rows) 228 | } 229 | 230 | columns := wt.columns 231 | if columns == 0 { 232 | columns = int(args.Columns) 233 | } 234 | 235 | wt.slave.ResizeTerminal(columns, rows) 236 | default: 237 | return errors.Errorf("unknown message type `%c`", data[0]) 238 | } 239 | 240 | return nil 241 | } 242 | 243 | type argResizeTerminal struct { 244 | Columns float64 245 | Rows float64 246 | } 247 | -------------------------------------------------------------------------------- /webtty/webtty_test.go: -------------------------------------------------------------------------------- 1 | package webtty 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "io" 8 | "sync" 9 | "testing" 10 | ) 11 | 12 | func TestInitialization(t *testing.T) { 13 | var wg sync.WaitGroup 14 | defer wg.Wait() 15 | 16 | mMaster, _, _, cancel := prepareSUT(t, &wg) 17 | defer cancel() 18 | 19 | // Check that the initialization happens as expected 20 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetWindowTitle) 21 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetBufferSize) 22 | } 23 | 24 | func TestInitializationWithPreferences(t *testing.T) { 25 | var wg sync.WaitGroup 26 | defer wg.Wait() 27 | 28 | mMaster, _, _, cancel := prepareSUT(t, &wg, WithMasterPreferences(map[string]string{"foo": "bar"})) 29 | defer cancel() 30 | 31 | // Check that the initialization happens as expected 32 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetWindowTitle) 33 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetBufferSize) 34 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetPreferences) 35 | } 36 | 37 | func TestInitializationWithReconnect(t *testing.T) { 38 | var wg sync.WaitGroup 39 | defer wg.Wait() 40 | 41 | mMaster, _, _, cancel := prepareSUT(t, &wg, WithReconnect(10)) 42 | defer cancel() 43 | 44 | // Check that the initialization happens as expected 45 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetWindowTitle) 46 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetBufferSize) 47 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetReconnect) 48 | } 49 | 50 | func TestWriteFromSlaveCommand(t *testing.T) { 51 | var wg sync.WaitGroup 52 | defer wg.Wait() 53 | 54 | mMaster, mSlave, _, cancel := prepareSUT(t, &wg) 55 | defer cancel() 56 | 57 | // Check that the initialization happens as expected 58 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetWindowTitle) 59 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetBufferSize) 60 | 61 | // Simulate the slave (the process being run by GoTTY) 62 | // echoing "foobar" 63 | message := []byte("foobar") 64 | mSlave.slaveToGottyWriter.Write(message) 65 | 66 | // And then make sure it makes it way to the client 67 | // through the websocket as an output message 68 | buf := make([]byte, 1024) 69 | n, err := mMaster.gottyToMasterReader.Read(buf) 70 | if err != nil { 71 | t.Fatalf("Unexpected error from Read(): %s", err) 72 | } 73 | if buf[0] != Output { 74 | t.Fatalf("Unexpected message type `%c`", buf[0]) 75 | } 76 | 77 | // Decode it and make sure it's intact 78 | decoded := make([]byte, 1024) 79 | n, err = base64.StdEncoding.Decode(decoded, buf[1:n]) 80 | if err != nil { 81 | t.Fatalf("Unexpected error from Decode(): %s", err) 82 | } 83 | if !bytes.Equal(decoded[:n], message) { 84 | t.Fatalf("Unexpected message received: `%s`", decoded[:n]) 85 | } 86 | 87 | cancel() 88 | wg.Wait() 89 | } 90 | func TestWriteFromFrontend(t *testing.T) { 91 | var wg sync.WaitGroup 92 | defer wg.Wait() 93 | 94 | mMaster, mSlave, _, cancel := prepareSUT(t, &wg, WithPermitWrite()) 95 | defer cancel() 96 | 97 | // Absorb initialization messages 98 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetWindowTitle) 99 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetBufferSize) 100 | 101 | // simulate input from frontend... 102 | message := []byte("1hello\n") // line buffered canonical mode 103 | mMaster.masterToGottyWriter.Write(message) 104 | 105 | // ...and make sure it makes it through to the slave intact 106 | readBuf := make([]byte, 1024) 107 | n, err := mSlave.gottyToSlaveReader.Read(readBuf) 108 | if err != nil { 109 | t.Fatalf("Unexpected error from Write(): %s", err) 110 | } 111 | if !bytes.Equal(readBuf[:n], message[1:]) { 112 | t.Fatalf("Unexpected message received: `%s`", readBuf[:n]) 113 | } 114 | } 115 | 116 | func TestPing(t *testing.T) { 117 | var wg sync.WaitGroup 118 | defer wg.Wait() 119 | 120 | mMaster, _, _, cancel := prepareSUT(t, &wg) 121 | defer cancel() 122 | 123 | // Absorb initialization messages 124 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetWindowTitle) 125 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetBufferSize) 126 | 127 | // ping 128 | message := []byte("2\n") // line buffered canonical mode 129 | n, err := mMaster.masterToGottyWriter.Write(message) 130 | if err != nil { 131 | t.Fatalf("Unexpected error from Write(): %s", err) 132 | } 133 | if n != len(message) { 134 | t.Fatalf("Write() accepted `%d` for message `%s`", n, message) 135 | } 136 | 137 | readBuf := make([]byte, 1024) 138 | n, err = mMaster.gottyToMasterReader.Read(readBuf) 139 | if err != nil { 140 | t.Fatalf("Unexpected error from Read(): %s", err) 141 | } 142 | if !bytes.Equal(readBuf[:n], []byte{'2'}) { 143 | t.Fatalf("Unexpected message received: `%s`", readBuf[:n]) 144 | } 145 | 146 | cancel() 147 | wg.Wait() 148 | } 149 | 150 | func TestResizeTerminal(t *testing.T) { 151 | var wg sync.WaitGroup 152 | defer wg.Wait() 153 | 154 | mMaster, mSlave, _, cancel := prepareSUT(t, &wg) 155 | defer cancel() 156 | 157 | // Absorb initialization messages 158 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetWindowTitle) 159 | checkNextMsgType(t, mMaster.gottyToMasterReader, SetBufferSize) 160 | 161 | message := []byte(`3{"Columns": 1234, "Rows": 2345}` + "\n") // line buffered canonical mode 162 | 163 | mSlave.wg.Add(1) 164 | n, err := mMaster.masterToGottyWriter.Write(message) 165 | if err != nil { 166 | t.Fatalf("Unexpected error from Write(): %s", err) 167 | } 168 | if n != len(message) { 169 | t.Fatalf("Write() accepted `%d` for message `%s`", n, message) 170 | } 171 | mSlave.wg.Wait() 172 | 173 | if mSlave.columns != 1234 { 174 | t.Fatalf("Columns not set correctly. Expected %v, got %v", 1234, mSlave.columns) 175 | } 176 | 177 | if mSlave.rows != 2345 { 178 | t.Fatalf("Rows not set correctly. Expected %v, got %v", 2345, mSlave.columns) 179 | } 180 | 181 | cancel() 182 | wg.Wait() 183 | } 184 | 185 | type mockMaster struct { 186 | gottyToMasterReader *io.PipeReader 187 | gottyToMasterWriter *io.PipeWriter 188 | masterToGottyReader *io.PipeReader 189 | masterToGottyWriter *io.PipeWriter 190 | } 191 | 192 | type mockSlave struct { 193 | gottyToSlaveReader *io.PipeReader 194 | gottyToSlaveWriter *io.PipeWriter 195 | slaveToGottyReader *io.PipeReader 196 | slaveToGottyWriter *io.PipeWriter 197 | wg sync.WaitGroup 198 | columns, rows int 199 | } 200 | 201 | func prepareSUT(t *testing.T, wg *sync.WaitGroup, options ...Option) (*mockMaster, *mockSlave, *WebTTY, context.CancelFunc) { 202 | mMaster := newMockMaster() 203 | mSlave := newMockSlave() 204 | 205 | dt, err := New(mMaster, mSlave, options...) 206 | if err != nil { 207 | t.Fatalf("Unexpected error from New(): %s", err) 208 | } 209 | 210 | ctx, cancel := context.WithCancel(context.Background()) 211 | wg.Add(1) 212 | go func() { 213 | wg.Done() 214 | dt.Run(ctx) 215 | }() 216 | return mMaster, mSlave, dt, cancel 217 | } 218 | 219 | func checkNextMsgType(t *testing.T, reader io.Reader, expected byte) { 220 | msgType, _ := nextMsg(t, reader) 221 | if msgType != expected { 222 | t.Fatalf("Unexpected message type `%c`", msgType) 223 | } 224 | } 225 | 226 | func nextMsg(t *testing.T, reader io.Reader) (byte, []byte) { 227 | buf := make([]byte, 1024) 228 | _, err := reader.Read(buf) 229 | if err != nil { 230 | t.Fatalf("unexpected error %v", err) 231 | } 232 | return buf[0], buf[1:] 233 | } 234 | 235 | func newMockMaster() *mockMaster { 236 | rv := &mockMaster{} 237 | rv.gottyToMasterReader, rv.gottyToMasterWriter = io.Pipe() 238 | rv.masterToGottyReader, rv.masterToGottyWriter = io.Pipe() 239 | return rv 240 | } 241 | 242 | func (mm *mockMaster) close() { 243 | mm.masterToGottyWriter.Close() 244 | mm.gottyToMasterReader.Close() 245 | } 246 | 247 | func (mm *mockMaster) Read(buf []byte) (int, error) { 248 | return mm.masterToGottyReader.Read(buf) 249 | } 250 | 251 | func (mm *mockMaster) Write(buf []byte) (int, error) { 252 | return mm.gottyToMasterWriter.Write(buf) 253 | } 254 | 255 | func newMockSlave() *mockSlave { 256 | rv := &mockSlave{} 257 | rv.gottyToSlaveReader, rv.gottyToSlaveWriter = io.Pipe() 258 | rv.slaveToGottyReader, rv.slaveToGottyWriter = io.Pipe() 259 | return rv 260 | } 261 | 262 | func (ms *mockSlave) close() { 263 | ms.slaveToGottyWriter.Close() 264 | ms.gottyToSlaveReader.Close() 265 | } 266 | 267 | func (ms *mockSlave) Read(buf []byte) (int, error) { 268 | return ms.slaveToGottyReader.Read(buf) 269 | } 270 | 271 | func (ms *mockSlave) Write(buf []byte) (int, error) { 272 | return ms.gottyToSlaveWriter.Write(buf) 273 | } 274 | 275 | func (ms *mockSlave) WindowTitleVariables() map[string]interface{} { 276 | return nil 277 | } 278 | 279 | func (ms *mockSlave) ResizeTerminal(columns int, rows int) error { 280 | ms.columns = columns 281 | ms.rows = rows 282 | ms.wg.Done() 283 | return nil 284 | } 285 | --------------------------------------------------------------------------------