fork repo
Some checks failed
backend / cross (aarch64) (push) Failing after 17s
backend / cross (armhf) (push) Failing after 31s
backend / cross (mips) (push) Failing after 31s
backend / cross (mips64) (push) Failing after 31s
backend / cross (mips64el) (push) Failing after 31s
frontend / build (push) Failing after 32s
backend / cross (arm) (push) Failing after 2m19s
backend / cross (i686) (push) Failing after 1s
backend / cross (mipsel) (push) Failing after 31s
backend / cross (s390x) (push) Failing after 31s
backend / cross (win32) (push) Failing after 31s
backend / cross (x86_64) (push) Failing after 32s
docker / build (push) Failing after 6m14s

This commit is contained in:
2025-09-02 21:03:35 +08:00
commit c79c776225
61 changed files with 45370 additions and 0 deletions

6
.clang-format Normal file
View File

@@ -0,0 +1,6 @@
BasedOnStyle: Google
Language: Cpp
ColumnLimit: 120
IndentWidth: 2
TabWidth: 2
UseTab: Never

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
src/html.h linguist-generated

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: tsl0922
patreon: tsl0922

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment:**
- OS: [e.g. macOS 10.15.2]
- Browser: [e.g. Chrome 79.0.3945.130]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,10 @@
---
name: Support Request
about: Support request or question
title: ''
labels: question
assignees: ''
---
Describe your problem or question here.

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/html"
schedule:
interval: daily
open-pull-requests-limit: 10

39
.github/workflows/backend.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: backend
on:
push:
paths:
- ".github/workflows/backend.yml"
- "CMakeLists.txt"
- "src/*"
- "scripts/*"
pull_request:
paths:
- ".github/workflows/backend.yml"
- "CMakeLists.txt"
- "src/*"
- "scripts/*"
workflow_call:
jobs:
cross:
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
target: [i686, x86_64, arm, armhf, aarch64, mips, mipsel, mips64, mips64el, s390x, win32]
steps:
- uses: actions/checkout@v4
- name: Install packages
run: |
sudo apt-get update
sudo apt-get install -y autoconf automake build-essential cmake curl file libtool
- name: Cross build (${{ matrix.target }})
env:
BUILD_TARGET: ${{ matrix.target }}
run: ./scripts/cross-build.sh
- uses: actions/upload-artifact@v4
with:
name: ttyd.${{ matrix.target }}
path: build/ttyd*

71
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: docker
on:
push:
branches: main
tags: ["*"]
jobs:
build:
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Install packages
run: |
sudo apt-get update
sudo apt-get install -y autoconf automake build-essential cmake curl file libtool
- name: Cross build multi-arch binary
run: |
mkdir dist
for arch in amd64 armv7 arm64 s390x; do
env BUILD_TARGET=$arch ./scripts/cross-build.sh
[ "$arch" = "armv7" ] && arch="arm"
mkdir -p dist/$arch && cp build/ttyd dist/$arch/ttyd
done
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine docker tags
id: docker_tag
run: |
case $GITHUB_REF in
refs/tags/*)
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "DOCKER_TAG=tsl0922/ttyd:${TAG_NAME}" >> $GITHUB_ENV
echo "ALPINE_TAG=tsl0922/ttyd:${TAG_NAME}-alpine" >> $GITHUB_ENV
;;
*)
echo "DOCKER_TAG=tsl0922/ttyd:latest" >> $GITHUB_ENV
echo "ALPINE_TAG=tsl0922/ttyd:alpine" >> $GITHUB_ENV
esac
- name: build/push docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/s390x
push: true
tags: |
${{ env.DOCKER_TAG }}
ghcr.io/${{ env.DOCKER_TAG }}
- name: build/push docker image (alpine)
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.alpine
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/s390x
push: true
tags: |
${{ env.ALPINE_TAG }}
ghcr.io/${{ env.ALPINE_TAG }}

28
.github/workflows/frontend.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: frontend
on:
push:
paths:
- ".github/workflows/frontend.yml"
- "html/*"
pull_request:
paths:
- ".github/workflows/frontend.yml"
- "html/*"
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Run yarn install, check and build
run: |
corepack enable
corepack prepare yarn@stable --activate
yarn install
yarn run check
yarn run build
working-directory: html

37
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: release
on:
push:
tags: ["*"]
jobs:
build:
uses: ./.github/workflows/backend.yml
publish:
needs: [build]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Check version bump
run: |
TAG=$(git describe --tags --match "[0-9]*.[0-9]*.[0-9]*" --abbrev=8)
VERSION=$(grep project CMakeLists.txt| awk '{print $3}')
if [ "$TAG" != "$VERSION" ]; then
echo "=== Version in CMakeLists.txt and git tag does not match!"
echo "=== Git Tag: $TAG, Version: $VERSION"
exit 1
fi
- uses: actions/download-artifact@v4
- run: |
mkdir build
for file in ttyd.*/*; do
target=$(echo $file | awk -F/ '{print $1}')
[[ $file == *.exe ]] && target="$target.exe"
mv $file build/$target
done
pushd build; sha256sum ttyd.* > SHA256SUMS; popd
- uses: ncipollo/release-action@v1
with:
artifacts: build/*
allowUpdates: true
draft: true

54
.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
# Prerequisites
*.d
# Object files
*.o
*.ko
*.obj
*.elf
# Precompiled Headers
*.gch
*.pch
# Libraries
*.lib
*.a
*.la
*.lo
# Shared objects (inc. Windows DLLs)
*.dll
*.so
*.so.*
*.dylib
# Executables
*.exe
*.out
*.app
*.i*86
*.x86_64
*.hex
# Debug files
*.dSYM/
*.su
# Cmake files
CMakeCache.txt
CMakeFiles
CMakeScripts
cmake_install.cmake
install_manifest.txt
CTestTestfile.cmake
build
# Clion files
.idea/
# VSCode files
.vscode/
# Project files
!init.d

94
CMakeLists.txt Normal file
View File

@@ -0,0 +1,94 @@
cmake_minimum_required(VERSION 3.12.0)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
project(ttyd VERSION 1.7.7 LANGUAGES C)
set(TTYD_VERSION "${PROJECT_VERSION}")
include(GetGitVersion)
get_git_version(GIT_VERSION SEM_VER)
get_git_head(GIT_COMMIT)
if("${SEM_VER}" VERSION_GREATER "${TTYD_VERSION}")
set(TTYD_VERSION "${SEM_VER}")
endif()
if(NOT "${GIT_COMMIT}" STREQUAL "")
set(TTYD_VERSION "${TTYD_VERSION}-${GIT_COMMIT}")
endif()
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -D_GNU_SOURCE")
if(CMAKE_VERSION VERSION_LESS "3.1")
if ("${CMAKE_C_COMPILER_ID}" STREQUAL "GNU")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=gnu99")
else()
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c99")
endif()
else()
set(CMAKE_C_STANDARD 99)
endif()
set(SOURCE_FILES src/utils.c src/pty.c src/protocol.c src/http.c src/server.c)
include(FindPackageHandleStandardArgs)
find_path(LIBUV_INCLUDE_DIR NAMES uv.h)
find_library(LIBUV_LIBRARY NAMES uv libuv)
find_package_handle_standard_args(LIBUV REQUIRED_VARS LIBUV_LIBRARY LIBUV_INCLUDE_DIR)
mark_as_advanced(LIBUV_INCLUDE_DIR LIBUV_LIBRARY)
if(LIBUV_FOUND)
SET(LIBUV_INCLUDE_DIRS "${LIBUV_INCLUDE_DIR}")
SET(LIBUV_LIBRARIES "${LIBUV_LIBRARY}")
endif()
find_path(JSON-C_INCLUDE_DIR NAMES json.h PATH_SUFFIXES json-c)
find_library(JSON-C_LIBRARY NAMES json-c)
find_package_handle_standard_args(JSON-C REQUIRED_VARS JSON-C_LIBRARY JSON-C_INCLUDE_DIR)
mark_as_advanced(JSON-C_INCLUDE_DIR JSON-C_LIBRARY)
if(JSON-C_FOUND)
SET(JSON-C_INCLUDE_DIRS "${JSON-C_INCLUDE_DIR}")
SET(JSON-C_LIBRARIES "${JSON-C_LIBRARY}")
endif()
find_package(ZLIB REQUIRED)
find_package(Libwebsockets 3.2.0 REQUIRED)
set(INCLUDE_DIRS ${ZLIB_INCLUDE_DIR} ${LIBWEBSOCKETS_INCLUDE_DIRS} ${JSON-C_INCLUDE_DIRS} ${LIBUV_INCLUDE_DIRS})
set(LINK_LIBS ${ZLIB_LIBRARIES} ${LIBWEBSOCKETS_LIBRARIES} ${JSON-C_LIBRARIES} ${LIBUV_LIBRARIES})
set (CMAKE_REQUIRED_INCLUDES ${INCLUDE_DIRS})
include(CheckSymbolExists)
check_symbol_exists(LWS_WITH_LIBUV "lws_config.h" LWS_WITH_LIBUV)
check_symbol_exists(LWS_OPENSSL_SUPPORT "lws_config.h" LWS_OPENSSL_ENABLED)
check_symbol_exists(LWS_WITH_MBEDTLS "lws_config.h" LWS_MBEDTLS_ENABLED)
if(NOT LWS_WITH_LIBUV)
message(FATAL_ERROR "libwebsockets was not build with libuv support (-DLWS_WITH_LIBUV=ON)")
endif()
if(LWS_OPENSSL_ENABLED AND NOT LWS_MBEDTLS_ENABLED)
find_package(OpenSSL REQUIRED)
list(APPEND INCLUDE_DIRS ${OPENSSL_INCLUDE_DIR})
list(APPEND LINK_LIBS ${OPENSSL_LIBRARIES})
endif()
if(WIN32)
list(APPEND LINK_LIBS shell32 ws2_32)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/app.rc.in ${CMAKE_CURRENT_BINARY_DIR}/app.rc @ONLY)
list(APPEND SOURCE_FILES ${CMAKE_CURRENT_BINARY_DIR}/app.rc)
else()
find_library(LIBUTIL NAMES util)
if(LIBUTIL)
list(APPEND LINK_LIBS util)
endif()
endif()
add_executable(${PROJECT_NAME} ${SOURCE_FILES})
target_include_directories(${PROJECT_NAME} PUBLIC ${INCLUDE_DIRS})
target_link_libraries(${PROJECT_NAME} ${LINK_LIBS})
target_compile_definitions(${PROJECT_NAME} PUBLIC
TTYD_VERSION="${TTYD_VERSION}"
$<$<PLATFORM_ID:Windows>:_WIN32_WINNT=0xa00 WINVER=0xa00>
)
include(GNUInstallDirs)
install(TARGETS ${PROJECT_NAME} DESTINATION "${CMAKE_INSTALL_BINDIR}" COMPONENT prog)
install(FILES man/ttyd.1 DESTINATION "${CMAKE_INSTALL_MANDIR}/man1" COMPONENT doc)

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM ubuntu:20.04
ARG TARGETARCH
# Dependencies
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
# Application
COPY ./dist/${TARGETARCH}/ttyd /usr/bin/ttyd
EXPOSE 7681
WORKDIR /root
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["ttyd", "-W", "bash"]

15
Dockerfile.alpine Normal file
View File

@@ -0,0 +1,15 @@
FROM alpine
ARG TARGETARCH
# Dependencies
RUN apk add --no-cache bash tini
# Application
COPY ./dist/${TARGETARCH}/ttyd /usr/bin/ttyd
EXPOSE 7681
WORKDIR /root
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["ttyd", "-W", "bash"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016-2025 Shuanglei Tao <tsl0922@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

108
README.md Normal file
View File

@@ -0,0 +1,108 @@
![backend](https://github.com/tsl0922/ttyd/workflows/backend/badge.svg)
![frontend](https://github.com/tsl0922/ttyd/workflows/frontend/badge.svg)
[![GitHub Releases](https://img.shields.io/github/downloads/tsl0922/ttyd/total)](https://github.com/tsl0922/ttyd/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/tsl0922/ttyd)](https://hub.docker.com/r/tsl0922/ttyd)
[![Packaging status](https://repology.org/badge/tiny-repos/ttyd.svg)](https://repology.org/project/ttyd/versions)
![GitHub](https://img.shields.io/github/license/tsl0922/ttyd)
# ttyd - Share your terminal over the web
ttyd is a simple command-line tool for sharing terminal over the web.
![screenshot](https://github.com/tsl0922/ttyd/raw/main/screenshot.gif)
# Features
- Built on top of [libuv](https://libuv.org) and [WebGL2](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API) for speed
- Fully-featured terminal with [CJK](https://en.wikipedia.org/wiki/CJK_characters) and IME support
- [ZMODEM](https://en.wikipedia.org/wiki/ZMODEM) ([lrzsz](https://ohse.de/uwe/software/lrzsz.html)) / [trzsz](https://trzsz.github.io) file transfer support
- [Sixel](https://en.wikipedia.org/wiki/Sixel) image output support ([img2sixel](https://saitoha.github.io/libsixel) / [lsix](https://github.com/hackerb9/lsix))
- SSL support based on [OpenSSL](https://www.openssl.org) / [Mbed TLS](https://github.com/Mbed-TLS/mbedtls)
- Run any custom command with options
- Basic authentication support and many other custom options
- Cross platform: macOS, Linux, FreeBSD/OpenBSD, [OpenWrt](https://openwrt.org), Windows
> ❤ Special thanks to [JetBrains](https://www.jetbrains.com/?from=ttyd) for sponsoring the opensource license to this project.
# Installation
## Install on macOS
- Install with [Homebrew](http://brew.sh): `brew install ttyd`
- Install with [MacPorts](https://www.macports.org): `sudo port install ttyd`
## Install on Linux
- Binary version (recommended): download from the [releases](https://github.com/tsl0922/ttyd/releases) page
- Install with [Homebrew](https://docs.brew.sh/Homebrew-on-Linux) : `brew install ttyd`
- Install the snap: `sudo snap install ttyd --classic`
- Build from source (debian/ubuntu):
```bash
sudo apt-get update
sudo apt-get install -y build-essential cmake git libjson-c-dev libwebsockets-dev
git clone https://github.com/tsl0922/ttyd.git
cd ttyd && mkdir build && cd build
cmake ..
make && sudo make install
```
You may also need to compile/install [libwebsockets](https://libwebsockets.org) from source if the `libwebsockets-dev` package is outdated.
- Install on OpenWrt: `opkg install ttyd`
- Install on Gentoo: clone the [repo](https://bitbucket.org/mgpagano/ttyd/src/master) and follow the directions [here](https://wiki.gentoo.org/wiki/Custom_repository#Creating_a_local_repository).
## Install on Windows
- Binary version (recommended): download from the [releases](https://github.com/tsl0922/ttyd/releases) page
- Install with [WinGet](https://github.com/microsoft/winget-cli): `winget install tsl0922.ttyd`
- Install with [Scoop](https://scoop.sh/#/apps?q=ttyd&s=2&d=1&o=true): `scoop install ttyd`
- [Compile on Windows](https://github.com/tsl0922/ttyd/wiki/Compile-on-Windows)
# Usage
## Command-line Options
```
USAGE:
ttyd [options] <command> [<arguments...>]
OPTIONS:
-p, --port Port to listen (default: 7681, use `0` for random port)
-i, --interface Network interface to bind (eg: eth0), or UNIX domain socket path (eg: /var/run/ttyd.sock)
-U, --socket-owner User owner of the UNIX domain socket file, when enabled (eg: user:group)
-c, --credential Credential for basic authentication (format: username:password)
-H, --auth-header HTTP Header name for auth proxy, this will configure ttyd to let a HTTP reverse proxy handle authentication
-u, --uid User id to run with
-g, --gid Group id to run with
-s, --signal Signal to send to the command when exit it (default: 1, SIGHUP)
-w, --cwd Working directory to be set for the child program
-a, --url-arg Allow client to send command line arguments in URL (eg: http://localhost:7681?arg=foo&arg=bar)
-W, --writable Allow clients to write to the TTY (readonly by default)
-t, --client-option Send option to client (format: key=value), repeat to add more options
-T, --terminal-type Terminal type to report, default: xterm-256color
-O, --check-origin Do not allow websocket connection from different origin
-m, --max-clients Maximum clients to support (default: 0, no limit)
-o, --once Accept only one client and exit on disconnection
-q, --exit-no-conn Exit on all clients disconnection
-B, --browser Open terminal with the default system browser
-I, --index Custom index.html path
-b, --base-path Expected base path for requests coming from a reverse proxy (eg: /mounted/here, max length: 128)
-P, --ping-interval Websocket ping interval(sec) (default: 5)
-6, --ipv6 Enable IPv6 support
-S, --ssl Enable SSL
-C, --ssl-cert SSL certificate file path
-K, --ssl-key SSL key file path
-A, --ssl-ca SSL CA file path for client certificate verification
-d, --debug Set log level (default: 7)
-v, --version Print the version and exit
-h, --help Print this text and exit
```
Read the example usage on the [wiki](https://github.com/tsl0922/ttyd/wiki/Example-Usage).
## Browser Support
Modern browsers, See [Browser Support](https://github.com/xtermjs/xterm.js#browser-support).
## Alternatives
* [Wetty](https://github.com/krishnasrinivas/wetty): [Node](https://nodejs.org) based web terminal (SSH/login)
* [GoTTY](https://github.com/yudai/gotty): [Go](https://golang.org) based web terminal

32
app.rc.in Normal file
View File

@@ -0,0 +1,32 @@
#include <winver.h>
#define VERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0
#define VERSION_STR "@PROJECT_VERSION_MAJOR@.@PROJECT_VERSION_MINOR@.@PROJECT_VERSION_PATCH@.0\0"
VS_VERSION_INFO VERSIONINFO
FILEVERSION VERSION
PRODUCTVERSION VERSION
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
FILEFLAGS 0
FILEOS VOS__WINDOWS32
FILETYPE VFT_DLL
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "FileDescription", "ttyd\0"
VALUE "ProductName", "ttyd\0"
VALUE "ProductVersion", VERSION_STR
VALUE "FileVersion", VERSION_STR
VALUE "InternalName", "ttyd\0"
VALUE "OriginalFilename", "ttyd.exe\0"
VALUE "LegalCopyright", "Copyright (C) 2016-2025 Shuanglei Tao\0"
VALUE "Comment", "\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END

47
cmake/GetGitVersion.cmake Normal file
View File

@@ -0,0 +1,47 @@
find_package(Git)
function(get_git_version var1 var2)
if(GIT_EXECUTABLE)
execute_process(
COMMAND ${GIT_EXECUTABLE} describe --tags --match "[0-9]*.[0-9]*.[0-9]*" --abbrev=8
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
RESULT_VARIABLE status
OUTPUT_VARIABLE GIT_VERSION
)
if (${status})
set(GIT_VERSION "0.0.0")
else()
string(STRIP ${GIT_VERSION} GIT_VERSION)
string(REGEX REPLACE "-[0-9]+-g" "-" GIT_VERSION ${GIT_VERSION})
endif()
else()
set(GIT_VERSION "0.0.0")
endif()
string(REGEX MATCH "^[0-9]+.[0-9]+.[0-9]+" SEM_VER "${GIT_VERSION}")
message("-- Git Tag: ${GIT_VERSION}, Sem Ver: ${SEM_VER}")
set(${var1} ${GIT_VERSION} PARENT_SCOPE)
set(${var2} ${SEM_VER} PARENT_SCOPE)
endfunction()
function(get_git_head var1)
if(GIT_EXECUTABLE)
execute_process(
COMMAND ${GIT_EXECUTABLE} --git-dir ${CMAKE_CURRENT_SOURCE_DIR}/.git rev-parse --short HEAD
RESULT_VARIABLE status
OUTPUT_VARIABLE GIT_COMMIT
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
if(${status})
set(GIT_COMMIT "unknown")
endif()
message("-- Git Commit: ${GIT_COMMIT}")
set(${var1} ${GIT_COMMIT} PARENT_SCOPE)
endif()
endfunction()

14
html/.editorconfig Normal file
View File

@@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{*.json, *.scss}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

2
html/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
dist/
src/

20
html/.eslintrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./node_modules/gts/",
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"parserOptions": {
"jsxPragma": "h"
},
"rules": {
"@typescript-eslint/no-duplicate-enum-values": "off"
}
},
{
"files": ["gulpfile.js", "webpack.config.js"],
"rules": {
"node/no-unpublished-require": "off"
}
}
]
}

9
html/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
*.log
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

6
html/.prettierrc.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
...require('gts/.prettierrc.json'),
"bracketSpacing": true,
"tabWidth": 4,
"printWidth": 120,
}

View File

@@ -0,0 +1,34 @@
diff --git a/src/zsession.js b/src/zsession.js
index 5f0b8f9d8afa6fba0acd6dd0477afa186f7aad9a..c7ea98e0f08c97d63d321f784a5dd8bf66888743 100644
--- a/src/zsession.js
+++ b/src/zsession.js
@@ -548,20 +548,17 @@ Zmodem.Session.Receive = class ZmodemReceiveSession extends Zmodem.Session {
if (this._got_ZFIN) {
if (this._input_buffer.length < 2) return;
- //if its OO, then set this._bytes_after_OO
- if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) === 0) {
+ if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) !== 0) {
+ console.warn( "PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " + this._input_buffer.join() );
+ }
- //This doubles as an indication that the session has ended.
- //We need to set this right away so that handlers like
- //"session_end" will have access to it.
- this._bytes_after_OO = _trim_OO(this._bytes_being_consumed.slice(0));
- this._on_session_end();
+ //This doubles as an indication that the session has ended.
+ //We need to set this right away so that handlers like
+ //"session_end" will have access to it.
+ this._bytes_after_OO = _trim_OO(this._bytes_being_consumed.slice(0));
+ this._on_session_end();
- return;
- }
- else {
- throw( "PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " + this._input_buffer.join() );
- }
+ return;
}
var parsed;

1
html/.yarnrc.yml Normal file
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

14
html/README.md Normal file
View File

@@ -0,0 +1,14 @@
## Prerequisites
> **NOTE:** yarn v2 is required.
Install [Yarn](https://yarnpkg.com/getting-started/install), and run: `yarn install`.
## Development
1. Start ttyd: `ttyd bash`
2. Start the dev server: `yarn run start`
## Publish
Run `yarn run build`, this will compile the inlined html to `../src/html.h`.

68
html/gulpfile.js Normal file
View File

@@ -0,0 +1,68 @@
const { src, dest, task, series } = require('gulp');
const clean = require('gulp-clean');
const gzip = require('gulp-gzip');
const inlineSource = require('gulp-inline-source');
const rename = require('gulp-rename');
const through2 = require('through2');
const genHeader = (size, buf, len) => {
let idx = 0;
let data = 'unsigned char index_html[] = {\n ';
for (const value of buf) {
idx++;
const current = value < 0 ? value + 256 : value;
data += '0x';
data += (current >>> 4).toString(16);
data += (current & 0xf).toString(16);
if (idx === len) {
data += '\n';
} else {
data += idx % 12 === 0 ? ',\n ' : ', ';
}
}
data += '};\n';
data += `unsigned int index_html_len = ${len};\n`;
data += `unsigned int index_html_size = ${size};\n`;
return data;
};
let fileSize = 0;
task('clean', () => {
return src('dist', { read: false, allowEmpty: true }).pipe(clean());
});
task('inline', () => {
const options = {
compress: false,
};
return src('dist/index.html').pipe(inlineSource(options)).pipe(rename('inline.html')).pipe(dest('dist/'));
});
task(
'default',
series('inline', () => {
return src('dist/inline.html')
.pipe(
through2.obj((file, enc, cb) => {
fileSize = file.contents.length;
return cb(null, file);
})
)
.pipe(gzip())
.pipe(
through2.obj((file, enc, cb) => {
const buf = file.contents;
file.contents = Buffer.from(genHeader(fileSize, buf, buf.length));
return cb(null, file);
})
)
.pipe(rename('html.h'))
.pipe(dest('../src/'));
})
);

15602
html/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
html/package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"private": true,
"name": "ttyd",
"version": "1.0.0",
"description": "Share your terminal over the web",
"repository": {
"url": "git@github.com:tsl0922/ttyd.git",
"type": "git"
},
"author": "Shuanglei Tao <tsl0922@gmail.com>",
"license": "MIT",
"scripts": {
"prestart": "gulp clean",
"start": "NODE_ENV=development && webpack serve",
"build": "NODE_ENV=production webpack && gulp",
"inline": "NODE_ENV=production webpack && gulp inline",
"check": "gts check",
"fix": "gts fix"
},
"engines": {
"node": ">=12"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.1.1",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"eslint": "^8.57.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-webpack-plugin": "^4.0.1",
"gts": "^5.2.0",
"gulp": "^4.0.2",
"gulp-clean": "^0.4.0",
"gulp-gzip": "^1.4.2",
"gulp-inline-source": "^4.0.0",
"gulp-rename": "^2.0.0",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.8.1",
"sass": "^1.71.1",
"sass-loader": "^14.1.1",
"scssfmt": "^1.0.7",
"style-loader": "^3.3.4",
"terser-webpack-plugin": "^5.3.10",
"through2": "^4.0.2",
"ts-loader": "^9.5.1",
"typescript": "^5.3.3",
"util": "^0.12.5",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.2",
"webpack-merge": "^5.10.0"
},
"dependencies": {
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-image": "^0.8.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"decko": "^1.2.0",
"file-saver": "^2.0.5",
"preact": "^10.19.6",
"trzsz": "^1.1.5",
"whatwg-fetch": "^3.6.20",
"zmodem.js": "^0.1.10"
},
"resolutions": {
"zmodem.js@^0.1.10": "patch:zmodem.js@npm%3A0.1.10#./.yarn/patches/zmodem.js-npm-0.1.10-e5537fa2ed.patch"
},
"packageManager": "yarn@3.6.3"
}

View File

@@ -0,0 +1,70 @@
import { h, Component } from 'preact';
import { Terminal } from './terminal';
import type { ITerminalOptions, ITheme } from '@xterm/xterm';
import type { ClientOptions, FlowControl } from './terminal/xterm';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const path = window.location.pathname.replace(/[/]+$/, '');
const wsUrl = [protocol, '//', window.location.host, path,'/ws'].join('');
const tokenUrl = [window.location.protocol, '//', window.location.host, path,'/token'].join('');
const clientOptions = {
rendererType: 'webgl',
disableLeaveAlert: false,
disableResizeOverlay: false,
enableZmodem: false,
enableTrzsz: false,
enableSixel: false,
closeOnDisconnect: false,
isWindows: false,
unicodeVersion: '11',
} as ClientOptions;
const termOptions = {
fontSize: 13,
fontFamily: 'Consolas,Liberation Mono,Menlo,Courier,monospace',
cursorBlink: true,
theme: {
foreground: '#d2d2d2',
background: '#2b2b2b',
cursor: '#adadad',
black: '#000000',
red: '#d81e00',
green: '#5ea702',
yellow: '#cfae00',
blue: '#427ab3',
magenta: '#89658e',
cyan: '#00a7aa',
white: '#dbded8',
brightBlack: '#686a66',
brightRed: '#f54235',
brightGreen: '#99e343',
brightYellow: '#fdeb61',
brightBlue: '#84b0d8',
brightMagenta: '#bc94b7',
brightCyan: '#37e6e8',
brightWhite: '#f1f1f0',
} as ITheme,
allowProposedApi: true,
} as ITerminalOptions;
const flowControl = {
limit: 100000,
highWater: 10,
lowWater: 4,
} as FlowControl;
export class App extends Component {
render() {
return (
<Terminal
id="terminal-container"
wsUrl={wsUrl}
tokenUrl={tokenUrl}
clientOptions={clientOptions}
termOptions={termOptions}
flowControl={flowControl}
/>
);
}
}

View File

@@ -0,0 +1,27 @@
import { h, Component, ComponentChildren } from 'preact';
import './modal.scss';
interface Props {
show: boolean;
children: ComponentChildren;
}
export class Modal extends Component<Props> {
constructor(props: Props) {
super(props);
}
render({ show, children }: Props) {
return (
show && (
<div className="modal">
<div className="modal-background" />
<div className="modal-content">
<div className="box">{children}</div>
</div>
</div>
)
);
}
}

View File

@@ -0,0 +1,81 @@
.modal {
bottom: 0;
left: 0;
right: 0;
top: 0;
align-items: center;
display: flex;
overflow: hidden;
position: fixed;
z-index: 40;
}
.modal-background {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
background-color: #4a4a4acc;
}
.modal-content {
margin: 0 20px;
max-height: calc(100vh - 160px);
overflow: auto;
position: relative;
width: 100%;
.box {
background-color: #fff;
color: #4a4a4a;
display: block;
padding: 1.25rem;
}
header {
font-weight: bold;
text-align: center;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid #ddd;
}
.file-input {
height: .01em;
left: 0;
outline: none;
position: absolute;
top: 0;
width: .01em;
}
.file-cta {
cursor: pointer;
background-color: #f5f5f5;
color: #6200ee;
outline: none;
align-items: center;
box-shadow: none;
display: inline-flex;
height: 2.25em;
justify-content: flex-start;
line-height: 1.5;
position: relative;
vertical-align: top;
border-color: #dbdbdb;
border-radius: 3px;
font-size: 1em;
font-weight: 500;
padding: calc(.375em - 1px) 1em;
white-space: nowrap;
}
}
@media print, screen and (min-width: 769px) {
.modal-content {
margin: 0 auto;
max-height: calc(100vh - 40px);
width: 640px;
}
}

View File

@@ -0,0 +1,152 @@
import { bind } from 'decko';
import { Component, h } from 'preact';
import { Xterm, XtermOptions } from './xterm';
import '@xterm/xterm/css/xterm.css';
import { Modal } from '../modal';
interface Props extends XtermOptions {
id: string;
}
interface State {
modal: boolean;
}
export class Terminal extends Component<Props, State> {
private container: HTMLElement;
private xterm: Xterm;
private intervalID: NodeJS.Timeout;
private currentDevcontainer = {
title: 'Devcontainer Info',
detail: 'No Devcontainer Created yet',
port: '',
ip:'',
steps: [
// {
// summary: '',
// duration: '',
// status: '',
// logs:{
// },
// }
],
};
constructor(props: Props) {
super();
this.xterm = new Xterm(props, this.showModal);
}
async componentDidMount() {
await this.xterm.refreshToken();
const options = new URLSearchParams(decodeURIComponent(window.location.search));
const params = new URLSearchParams({
repo: options.get('repoid') as string,
user: options.get('userid') as string,
});
fetch('http://' +
options.get('domain') +
':'+
options.get('port') +
'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/status?' +
params
)
.then(response => response.json())
.then(data => {
if (data.status !== '-1') {
if (options.get('type') === 'docker') {
this.xterm.open(this.container);
this.xterm.connect();
} else {
this.intervalID = setInterval(this.loadOutput, 3000);
this.xterm.open(this.container);
this.xterm.changeUrl(this.currentDevcontainer.ip, this.currentDevcontainer.port)
this.xterm.changeStatus(true);
this.xterm.connect();
}
}
})
.catch(error => {
console.error('Error:', error);
});
}
componentWillUnmount() {
this.xterm.dispose();
}
render({ id }: Props, { modal }: State) {
return (
<div id={id} ref={c => (this.container = c as HTMLElement)}>
<Modal show={modal}>
<label class="file-label">
<input onChange={this.sendFile} class="file-input" type="file" multiple />
<span class="file-cta">Choose files</span>
</label>
</Modal>
</div>
);
}
@bind
showModal() {
this.setState({ modal: true });
}
@bind
sendFile(event: Event) {
this.setState({ modal: false });
const files = (event.target as HTMLInputElement).files;
if (files) this.xterm.sendFile(files);
}
@bind
private loadOutput() {
const options = new URLSearchParams(decodeURIComponent(window.location.search));
const params = new URLSearchParams({
repo: options.get('repoid') as string,
user: options.get('userid') as string,
});
fetch(
'http://' + options.get('domain') + ':'+ options.get('port') +'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/output?' +
params
)
.then(response => response.json())
.then(job => {
if (!job) {
clearInterval(this.intervalID);
this.intervalID = null as any;
return;
}
if(this.currentDevcontainer.steps.length < job.currentDevcontainer.steps.length){
for(let i = this.currentDevcontainer.steps.length; i < job.currentDevcontainer.steps.length; i++) {
this.xterm.writeData(job.currentDevcontainer.steps[i].summary);
this.xterm.writeData('\r\n');
for(let j = 0; j < job.currentDevcontainer.steps[i].logs.length; j++) {
this.xterm.writeData(job.currentDevcontainer.steps[i].logs[j].message.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
this.xterm.writeData('\r\n');
}
}
}
this.currentDevcontainer = job.currentDevcontainer;
if (this.currentDevcontainer.detail === '4' && this.intervalID) {
clearInterval(this.intervalID);
this.intervalID = null as any;
}
})
.catch(error => {
console.error('Error:', error);
});
}
}

View File

@@ -0,0 +1,73 @@
// ported from hterm.Terminal.prototype.showOverlay
// https://chromium.googlesource.com/apps/libapps/+/master/hterm/js/hterm_terminal.js
import { bind } from 'decko';
import { ITerminalAddon, Terminal } from '@xterm/xterm';
export class OverlayAddon implements ITerminalAddon {
private terminal: Terminal;
private overlayNode: HTMLElement;
private overlayTimeout?: number;
constructor() {
this.overlayNode = document.createElement('div');
this.overlayNode.style.cssText = `border-radius: 15px;
font-size: xx-large;
opacity: 0.75;
padding: 0.2em 0.5em 0.2em 0.5em;
position: absolute;
-webkit-user-select: none;
-webkit-transition: opacity 180ms ease-in;
-moz-user-select: none;
-moz-transition: opacity 180ms ease-in;`;
this.overlayNode.addEventListener(
'mousedown',
e => {
e.preventDefault();
e.stopPropagation();
},
true
);
}
activate(terminal: Terminal): void {
this.terminal = terminal;
}
dispose(): void {}
@bind
showOverlay(msg: string, timeout?: number): void {
const { terminal, overlayNode } = this;
if (!terminal.element) return;
overlayNode.style.color = '#101010';
overlayNode.style.backgroundColor = '#f0f0f0';
overlayNode.textContent = msg;
overlayNode.style.opacity = '0.75';
if (!overlayNode.parentNode) {
terminal.element.appendChild(overlayNode);
}
const divSize = terminal.element.getBoundingClientRect();
const overlaySize = overlayNode.getBoundingClientRect();
overlayNode.style.top = (divSize.height - overlaySize.height) / 2 + 'px';
overlayNode.style.left = (divSize.width - overlaySize.width) / 2 + 'px';
if (this.overlayTimeout) clearTimeout(this.overlayTimeout);
if (!timeout) return;
this.overlayTimeout = window.setTimeout(() => {
overlayNode.style.opacity = '0';
this.overlayTimeout = window.setTimeout(() => {
if (overlayNode.parentNode) {
overlayNode.parentNode.removeChild(overlayNode);
}
this.overlayTimeout = undefined;
overlayNode.style.opacity = '0.75';
}, 200);
}, timeout || 1500);
}
}

View File

@@ -0,0 +1,182 @@
import { bind } from 'decko';
import { saveAs } from 'file-saver';
import { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
import * as Zmodem from 'zmodem.js/src/zmodem_browser';
import { TrzszFilter } from 'trzsz';
export interface ZmodeOptions {
zmodem: boolean;
trzsz: boolean;
windows: boolean;
trzszDragInitTimeout: number;
onSend: () => void;
sender: (data: string | Uint8Array) => void;
writer: (data: string | Uint8Array) => void;
}
export class ZmodemAddon implements ITerminalAddon {
private disposables: IDisposable[] = [];
private terminal: Terminal;
private sentry: Zmodem.Sentry;
private session: Zmodem.Session;
private denier: () => void;
private trzszFilter: TrzszFilter;
constructor(private options: ZmodeOptions) {}
activate(terminal: Terminal) {
this.terminal = terminal;
if (this.options.zmodem) this.zmodemInit();
if (this.options.trzsz) this.trzszInit();
}
dispose() {
for (const d of this.disposables) {
d.dispose();
}
this.disposables.length = 0;
}
consume(data: ArrayBuffer) {
try {
if (this.options.trzsz) {
this.trzszFilter.processServerOutput(data);
} else {
this.sentry.consume(data);
}
} catch (e) {
console.error('[ttyd] zmodem consume: ', e);
this.reset();
}
}
@bind
private reset() {
this.terminal.options.disableStdin = false;
this.terminal.focus();
}
private addDisposableListener(target: EventTarget, type: string, listener: EventListener) {
target.addEventListener(type, listener);
this.disposables.push({ dispose: () => target.removeEventListener(type, listener) });
}
@bind
private trzszInit() {
const { terminal } = this;
const { sender, writer, zmodem } = this.options;
this.trzszFilter = new TrzszFilter({
writeToTerminal: data => {
if (!this.trzszFilter.isTransferringFiles() && zmodem) {
this.sentry.consume(data);
} else {
writer(typeof data === 'string' ? data : new Uint8Array(data as ArrayBuffer));
}
},
sendToServer: data => sender(data),
terminalColumns: terminal.cols,
isWindowsShell: this.options.windows,
dragInitTimeout: this.options.trzszDragInitTimeout,
});
const element = terminal.element as EventTarget;
this.addDisposableListener(element, 'dragover', event => event.preventDefault());
this.addDisposableListener(element, 'drop', event => {
event.preventDefault();
this.trzszFilter
.uploadFiles((event as DragEvent).dataTransfer?.items as DataTransferItemList)
.then(() => console.log('[ttyd] upload success'))
.catch(err => console.log('[ttyd] upload failed: ' + err));
});
this.disposables.push(terminal.onResize(size => this.trzszFilter.setTerminalColumns(size.cols)));
}
@bind
private zmodemInit() {
const { sender, writer } = this.options;
const { terminal, reset, zmodemDetect } = this;
this.session = null;
this.sentry = new Zmodem.Sentry({
to_terminal: octets => writer(new Uint8Array(octets)),
sender: octets => sender(new Uint8Array(octets)),
on_retract: () => reset(),
on_detect: detection => zmodemDetect(detection),
});
this.disposables.push(
terminal.onKey(e => {
const event = e.domEvent;
if (event.ctrlKey && event.key === 'c') {
if (this.denier) this.denier();
}
})
);
}
@bind
private zmodemDetect(detection: Zmodem.Detection): void {
const { terminal, receiveFile } = this;
terminal.options.disableStdin = true;
this.denier = () => detection.deny();
this.session = detection.confirm();
this.session.on('session_end', () => this.reset());
if (this.session.type === 'send') {
this.options.onSend();
} else {
receiveFile();
}
}
@bind
public sendFile(files: FileList) {
const { session, writeProgress } = this;
Zmodem.Browser.send_files(session, files, {
on_progress: (_, offer) => writeProgress(offer),
})
.then(() => session.close())
.catch(() => this.reset());
}
@bind
private receiveFile() {
const { session, writeProgress } = this;
session.on('offer', offer => {
offer.on('input', () => writeProgress(offer));
offer
.accept()
.then(payloads => {
const blob = new Blob(payloads, { type: 'application/octet-stream' });
saveAs(blob, offer.get_details().name);
})
.catch(() => this.reset());
});
session.start();
}
@bind
private writeProgress(offer: Zmodem.Offer) {
const { bytesHuman } = this;
const file = offer.get_details();
const name = file.name;
const size = file.size;
const offset = offer.get_offset();
const percent = ((100 * offset) / size).toFixed(2);
this.options.writer(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private bytesHuman(bytes: any, precision: number): string {
if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(bytes)) {
return '-';
}
if (bytes === 0) return '0';
if (typeof precision === 'undefined') precision = 1;
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const num = Math.floor(Math.log(bytes) / Math.log(1024));
const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision);
return `${value} ${units[num]}`;
}
}

View File

@@ -0,0 +1,652 @@
import { bind } from 'decko';
import type { IDisposable, ITerminalOptions } from '@xterm/xterm';
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { WebglAddon } from '@xterm/addon-webgl';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { ImageAddon } from '@xterm/addon-image';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { OverlayAddon } from './addons/overlay';
import { ZmodemAddon } from './addons/zmodem';
import '@xterm/xterm/css/xterm.css';
interface TtydTerminal extends Terminal {
fit(): void;
}
declare global {
interface Window {
term: TtydTerminal;
}
}
enum Command {
// server side
OUTPUT = '0',
SET_WINDOW_TITLE = '1',
SET_PREFERENCES = '2',
// client side
INPUT = '0',
RESIZE_TERMINAL = '1',
PAUSE = '2',
RESUME = '3',
}
type Preferences = ITerminalOptions & ClientOptions;
export type RendererType = 'dom' | 'canvas' | 'webgl';
export interface ClientOptions {
rendererType: RendererType;
disableLeaveAlert: boolean;
disableResizeOverlay: boolean;
enableZmodem: boolean;
enableTrzsz: boolean;
enableSixel: boolean;
titleFixed?: string;
isWindows: boolean;
trzszDragInitTimeout: number;
unicodeVersion: string;
closeOnDisconnect: boolean;
}
export interface FlowControl {
limit: number;
highWater: number;
lowWater: number;
}
export interface XtermOptions {
wsUrl: string;
tokenUrl: string;
flowControl: FlowControl;
clientOptions: ClientOptions;
termOptions: ITerminalOptions;
}
function toDisposable(f: () => void): IDisposable {
return { dispose: f };
}
function addEventListener(target: EventTarget, type: string, listener: EventListener): IDisposable {
target.addEventListener(type, listener);
return toDisposable(() => target.removeEventListener(type, listener));
}
export class Xterm {
private disposables: IDisposable[] = [];
private textEncoder = new TextEncoder();
private textDecoder = new TextDecoder();
private written = 0;
private pending = 0;
private terminal: Terminal;
private fitAddon = new FitAddon();
private overlayAddon = new OverlayAddon();
private clipboardAddon = new ClipboardAddon();
private webLinksAddon = new WebLinksAddon();
private webglAddon?: WebglAddon;
private canvasAddon?: CanvasAddon;
private zmodemAddon?: ZmodemAddon;
private socket?: WebSocket;
private token: string;
private opened = false;
private title?: string;
private titleFixed?: string;
private resizeOverlay = true;
private reconnect = true;
private doReconnect = true;
private closeOnDisconnect = false;
private intervalID: NodeJS.Timeout;
private writeFunc = (data: ArrayBuffer) => this.writeData(new Uint8Array(data));
private status = false;
private titleStatus = false;
private checkStatus = false;
private connectStatus = false;
private beforeCommand?: string;
constructor(
private options: XtermOptions,
private sendCb: () => void
) {}
dispose() {
for (const d of this.disposables) {
d.dispose();
}
this.disposables.length = 0;
}
@bind
private register<T extends IDisposable>(d: T): T {
this.disposables.push(d);
return d;
}
@bind
public sendFile(files: FileList) {
this.zmodemAddon?.sendFile(files);
}
@bind
public async refreshToken() {
try {
const resp = await fetch(this.options.tokenUrl);
if (resp.ok) {
const json = await resp.json();
this.token = json.token;
}
} catch (e) {
console.error(`[ttyd] fetch ${this.options.tokenUrl}: `, e);
}
}
@bind
private onWindowUnload(event: BeforeUnloadEvent) {
event.preventDefault();
if (this.socket?.readyState === WebSocket.OPEN) {
const message = 'Close terminal? this will also terminate the command.';
event.returnValue = message;
return message;
}
return undefined;
}
@bind
public open(parent: HTMLElement) {
this.terminal = new Terminal(this.options.termOptions);
const { terminal, fitAddon, overlayAddon, clipboardAddon, webLinksAddon } = this;
window.term = terminal as TtydTerminal;
window.term.fit = () => {
this.fitAddon.fit();
};
terminal.loadAddon(fitAddon);
terminal.loadAddon(overlayAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(parent);
fitAddon.fit();
}
@bind
private initListeners() {
const { terminal, fitAddon, overlayAddon, register, sendData } = this;
register(
terminal.onTitleChange(data => {
if (data && data !== '' && !this.titleFixed) {
document.title = data + ' | ' + this.title;
}
})
);
register(
terminal.onData(data =>
{
if (this.status) {
sendData(data);
} else {
this.writeData('\b \b');
}
})
);
register(terminal.onBinary(data => sendData(Uint8Array.from(data, v => v.charCodeAt(0)))));
register(
terminal.onResize(({ cols, rows }) => {
const msg = JSON.stringify({ columns: cols, rows: rows });
this.socket?.send(this.textEncoder.encode(Command.RESIZE_TERMINAL + msg));
if (this.resizeOverlay) overlayAddon.showOverlay(`${cols}x${rows}`, 300);
})
);
register(
terminal.onSelectionChange(() => {
if (this.terminal.getSelection() === '') return;
try {
document.execCommand('copy');
} catch (e) {
return;
}
this.overlayAddon?.showOverlay('\u2702', 200);
})
);
register(addEventListener(window, 'resize', () => fitAddon.fit()));
register(addEventListener(window, 'beforeunload', this.onWindowUnload));
}
@bind
public writeData(data: string | Uint8Array) {
const { terminal, textEncoder } = this;
const { limit, highWater, lowWater } = this.options.flowControl;
this.written += data.length;
if (this.written > limit) {
terminal.write(data, () => {
this.pending = Math.max(this.pending - 1, 0);
if (this.pending < lowWater) {
this.socket?.send(textEncoder.encode(Command.RESUME));
}
});
this.pending++;
this.written = 0;
if (this.pending > highWater) {
this.socket?.send(textEncoder.encode(Command.PAUSE));
}
} else {
terminal.write(data);
}
}
@bind
public sendData(data: string | Uint8Array) {
const { socket, textEncoder } = this;
if (socket?.readyState !== WebSocket.OPEN) return;
if (typeof data === 'string') {
const payload = new Uint8Array(data.length * 3 + 1);
payload[0] = Command.INPUT.charCodeAt(0);
const stats = textEncoder.encodeInto(data, payload.subarray(1));
socket.send(payload.subarray(0, (stats.written as number) + 1));
} else {
const payload = new Uint8Array(data.length + 1);
payload[0] = Command.INPUT.charCodeAt(0);
payload.set(data, 1);
socket.send(payload);
}
}
@bind
public changeUrl(ip: string, port: string) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.options.wsUrl = [protocol, '//' + ip + ':' + port +'/ws', window.location.search].join('');
this.options.tokenUrl = [window.location.protocol, '//' + ip + ':' + port +'/token'].join('');
}
@bind
public changeStatus(v: boolean){
this.status = v;
}
@bind
public connect() {
this.socket = new WebSocket(this.options.wsUrl, ['tty']);
const { socket, register } = this;
socket.binaryType = 'arraybuffer';
register(addEventListener(socket, 'open', this.onSocketOpen));
register(addEventListener(socket, 'message', this.onSocketData as EventListener));
register(addEventListener(socket, 'close', this.onSocketClose as EventListener));
register(addEventListener(socket, 'error', () => (this.doReconnect = false)));
const options = new URLSearchParams(decodeURIComponent(window.location.search));
if (options.get('type') === 'docker') {
this.intervalID = setInterval(this.loadCommand, 3000);
}
}
@bind
private onSocketOpen() {
console.log('[ttyd] websocket connection opened');
const { textEncoder, terminal, overlayAddon } = this;
const msg = JSON.stringify({ AuthToken: this.token, columns: terminal.cols, rows: terminal.rows });
this.socket?.send(textEncoder.encode(msg));
if (this.opened) {
terminal.reset();
terminal.options.disableStdin = false;
overlayAddon.showOverlay('Reconnected', 300);
} else {
this.opened = true;
}
this.doReconnect = this.reconnect;
this.initListeners();
terminal.focus();
}
@bind
private onSocketClose(event: CloseEvent) {
console.log(`[ttyd] websocket connection closed with code: ${event.code}`);
const { refreshToken, connect, doReconnect, overlayAddon } = this;
overlayAddon.showOverlay('Connection Closed');
this.dispose();
// 1000: CLOSE_NORMAL
if (event.code !== 1000 && doReconnect) {
overlayAddon.showOverlay('Reconnecting...');
refreshToken().then(connect);
} else if (this.closeOnDisconnect) {
window.close();
} else {
const { terminal } = this;
const keyDispose = terminal.onKey(e => {
const event = e.domEvent;
if (event.key === 'Enter') {
keyDispose.dispose();
overlayAddon.showOverlay('Reconnecting...');
refreshToken().then(connect);
}
});
overlayAddon.showOverlay('Press ⏎ to Reconnect');
}
}
@bind
private loadCommand() {
const options = new URLSearchParams(decodeURIComponent(window.location.search));
const params = new URLSearchParams({
repo: options.get('repoid') as string,
user: options.get('userid') as string,
});
fetch(
'http://' + options.get('domain') + ':'+ options.get('port') +'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/command?' +
params
)
.then(response => response.json())
.then(data => {
if (data.status !== '4' && data.status !== '0') {
this.sendData(data.command);
} else {
clearInterval(this.intervalID);
if (data.status === '4') {
fetch(
'http://' + options.get('domain') + ':'+ options.get('port') +'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/command?' +
params
)
.then(response => response.json())
.then(data => {
this.sendData(data.command);
})
.catch(error => {
console.error('Error:', error);
});
}
}
})
.catch(error => {
console.error('Error:', error);
});
}
@bind
private parseOptsFromUrlQuery(query: string): Preferences {
const { terminal } = this;
const { clientOptions } = this.options;
const prefs = {} as Preferences;
const queryObj = Array.from(new URLSearchParams(query) as unknown as Iterable<[string, string]>);
for (const [k, queryVal] of queryObj) {
let v = clientOptions[k];
if (v === undefined) v = terminal.options[k];
switch (typeof v) {
case 'boolean':
prefs[k] = queryVal === 'true' || queryVal === '1';
break;
case 'number':
case 'bigint':
prefs[k] = Number.parseInt(queryVal, 10);
break;
case 'string':
prefs[k] = queryVal;
break;
case 'object':
prefs[k] = JSON.parse(queryVal);
break;
default:
console.warn(`[ttyd] maybe unknown option: ${k}=${queryVal}, treating as string`);
prefs[k] = queryVal;
break;
}
}
return prefs;
}
@bind
private onSocketData(event: MessageEvent) {
const { textDecoder } = this;
const rawData = event.data as ArrayBuffer;
const cmd = String.fromCharCode(new Uint8Array(rawData)[0]);
const data = rawData.slice(1);
switch (cmd) {
case Command.OUTPUT:
const options = new URLSearchParams(decodeURIComponent(window.location.search));
if (options.get('type') === 'docker') {
if (
this.status === false &&
textDecoder.decode(data).replace(/\s/g, '').includes('Successfully connected to the container'.replace(/\s/g, ''))
) {
if(this.connectStatus == true){
this.status = true;
}
this.connectStatus = true;
}
if (this.checkStatus) {
if (textDecoder.decode(data).replace(/\s/g, '').includes('You have out the container. Please refresh the terminal to reconnect.'.replace(/\s/g, ''))) {
this.checkStatus = false;
this.status = false;
this.connectStatus = false;
}
if(textDecoder.decode(data).includes('\x1b')){
this.checkStatus = false;
}
}
if (this.titleStatus && textDecoder.decode(data).includes('\x1b')) {
this.titleStatus = false;
this.checkStatus = true;
this.sendData(
`echo $WEB_TERMINAL\n`
);
}
if (
!(this.status === false && (textDecoder.decode(data).includes('\x1b') || textDecoder.decode(data).replace(/\s/g, '').includes('docker'))) &&
this.titleStatus !== true &&
this.checkStatus !== true
){
this.writeFunc(data);
}
if (textDecoder.decode(data).replace(/\s/g, '').includes('exit')) {
this.titleStatus = true;
}
} else {
this.writeFunc(data);
}
break;
case Command.SET_WINDOW_TITLE:
console.log('SET_WINDOW_TITLESET_WINDOW_TITLE');
this.title = textDecoder.decode(data);
document.title = this.title;
break;
case Command.SET_PREFERENCES:
console.log('SET_PREFERENCESSET_PREFERENCESSET_PREFERENCES');
this.applyPreferences({
...this.options.clientOptions,
...JSON.parse(textDecoder.decode(data)),
...this.parseOptsFromUrlQuery(window.location.search),
} as Preferences);
break;
default:
console.warn(`[ttyd] unknown command: ${cmd}`);
break;
}
}
@bind
private applyPreferences(prefs: Preferences) {
const { terminal, fitAddon, register } = this;
if (prefs.enableZmodem || prefs.enableTrzsz) {
this.zmodemAddon = new ZmodemAddon({
zmodem: prefs.enableZmodem,
trzsz: prefs.enableTrzsz,
windows: prefs.isWindows,
trzszDragInitTimeout: prefs.trzszDragInitTimeout,
onSend: this.sendCb,
sender: this.sendData,
writer: this.writeData,
});
this.writeFunc = data => this.zmodemAddon?.consume(data);
terminal.loadAddon(register(this.zmodemAddon));
}
for (const [key, value] of Object.entries(prefs)) {
switch (key) {
case 'rendererType':
this.setRendererType(value);
break;
case 'disableLeaveAlert':
if (value) {
window.removeEventListener('beforeunload', this.onWindowUnload);
console.log('[ttyd] Leave site alert disabled');
}
break;
case 'disableResizeOverlay':
if (value) {
console.log('[ttyd] Resize overlay disabled');
this.resizeOverlay = false;
}
break;
case 'disableReconnect':
if (value) {
console.log('[ttyd] Reconnect disabled');
this.reconnect = false;
this.doReconnect = false;
}
break;
case 'enableZmodem':
if (value) console.log('[ttyd] Zmodem enabled');
break;
case 'enableTrzsz':
if (value) console.log('[ttyd] trzsz enabled');
break;
case 'trzszDragInitTimeout':
if (value) console.log(`[ttyd] trzsz drag init timeout: ${value}`);
break;
case 'enableSixel':
if (value) {
terminal.loadAddon(register(new ImageAddon()));
console.log('[ttyd] Sixel enabled');
}
break;
case 'closeOnDisconnect':
if (value) {
console.log('[ttyd] close on disconnect enabled (Reconnect disabled)');
this.closeOnDisconnect = true;
this.reconnect = false;
this.doReconnect = false;
}
break;
case 'titleFixed':
if (!value || value === '') return;
console.log(`[ttyd] setting fixed title: ${value}`);
this.titleFixed = value;
document.title = value;
break;
case 'isWindows':
if (value) console.log('[ttyd] is windows');
break;
case 'unicodeVersion':
switch (value) {
case 6:
case '6':
console.log('[ttyd] setting Unicode version: 6');
break;
case 11:
case '11':
default:
console.log('[ttyd] setting Unicode version: 11');
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = '11';
break;
}
break;
default:
console.log(`[ttyd] option: ${key}=${JSON.stringify(value)}`);
if (terminal.options[key] instanceof Object) {
terminal.options[key] = Object.assign({}, terminal.options[key], value);
} else {
terminal.options[key] = value;
}
if (key.indexOf('font') === 0) fitAddon.fit();
break;
}
}
}
@bind
private setRendererType(value: RendererType) {
const { terminal } = this;
const disposeCanvasRenderer = () => {
try {
this.canvasAddon?.dispose();
} catch {
// ignore
}
this.canvasAddon = undefined;
};
const disposeWebglRenderer = () => {
try {
this.webglAddon?.dispose();
} catch {
// ignore
}
this.webglAddon = undefined;
};
const enableCanvasRenderer = () => {
if (this.canvasAddon) return;
this.canvasAddon = new CanvasAddon();
disposeWebglRenderer();
try {
this.terminal.loadAddon(this.canvasAddon);
console.log('[ttyd] canvas renderer loaded');
} catch (e) {
console.log('[ttyd] canvas renderer could not be loaded, falling back to dom renderer', e);
disposeCanvasRenderer();
}
};
const enableWebglRenderer = () => {
if (this.webglAddon) return;
this.webglAddon = new WebglAddon();
disposeCanvasRenderer();
try {
this.webglAddon.onContextLoss(() => {
this.webglAddon?.dispose();
});
terminal.loadAddon(this.webglAddon);
console.log('[ttyd] WebGL renderer loaded');
} catch (e) {
console.log('[ttyd] WebGL renderer could not be loaded, falling back to canvas renderer', e);
disposeWebglRenderer();
enableCanvasRenderer();
}
};
switch (value) {
case 'canvas':
enableCanvasRenderer();
break;
case 'webgl':
enableWebglRenderer();
break;
case 'dom':
disposeWebglRenderer();
disposeCanvasRenderer();
console.log('[ttyd] dom renderer loaded');
break;
default:
break;
}
}
}

BIN
html/src/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

9
html/src/index.tsx Normal file
View File

@@ -0,0 +1,9 @@
if (process.env.NODE_ENV === 'development') {
require('preact/debug');
}
import 'whatwg-fetch';
import { h, render } from 'preact';
import { App } from './components/app';
import './style/index.scss';
render(<App />, document.body);

18
html/src/style/index.scss Normal file
View File

@@ -0,0 +1,18 @@
html,
body {
height: 100%;
min-height: 100%;
margin: 0;
overflow: hidden;
}
#terminal-container {
width: auto;
height: 100%;
margin: 0 auto;
padding: 0;
.terminal {
padding: 5px;
height: calc(100% - 10px);
}
}

18
html/src/template.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="mobile-web-app-capable" content="yes">
<title><%= htmlWebpackPlugin.options.title %></title>
<link inline rel="icon" type="image/png" href="favicon.png">
<% for (const css in htmlWebpackPlugin.files.css) { %>
<link inline rel="stylesheet" type="text/css" href="<%= htmlWebpackPlugin.files.css[css] %>">
<% } %>
</head>
<body>
<% for (const js in htmlWebpackPlugin.files.js) { %>
<script inline type="text/javascript" src="<%= htmlWebpackPlugin.files.js[js] %>"></script>
<% } %>
</body>
</html>

19
html/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./node_modules/gts/tsconfig-google.json",
"compilerOptions": {
"moduleResolution": "node",
"esModuleInterop": true,
"jsx": "react",
"jsxFactory": "h",
"allowJs": true,
"noImplicitAny": false,
"declaration": false,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"lib": ["es2019", "dom"],
},
"include": [
"src/**/*.tsx",
"src/**/*.ts"
]
}

101
html/webpack.config.js Normal file
View File

@@ -0,0 +1,101 @@
const path = require('path');
const { merge } = require('webpack-merge');
const ESLintPlugin = require('eslint-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const devMode = process.env.NODE_ENV !== 'production';
const baseConfig = {
context: path.resolve(__dirname, 'src'),
entry: {
app: './index.tsx',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: devMode ? '[name].js' : '[name].[contenthash].js',
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.s?[ac]ss$/,
use: [devMode ? 'style-loader' : MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [
new ESLintPlugin({
context: path.resolve(__dirname, '.'),
extensions: ['js', 'jsx', 'ts', 'tsx'],
}),
new CopyWebpackPlugin({
patterns: [{ from: './favicon.png', to: '.' }],
}),
new MiniCssExtractPlugin({
filename: devMode ? '[name].css' : '[name].[contenthash].css',
chunkFilename: devMode ? '[id].css' : '[id].[contenthash].css',
}),
new HtmlWebpackPlugin({
inject: false,
minify: {
removeComments: true,
collapseWhitespace: true,
},
title: 'ttyd - Terminal',
template: './template.html',
}),
],
performance: {
hints: false,
},
};
const devConfig = {
mode: 'development',
devServer: {
static: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
client: {
overlay: {
errors: true,
warnings: false,
},
},
proxy: [
{
context: ['/token', '/ws'],
target: 'http://localhost:7681',
ws: true,
},
],
webSocketServer: {
type: 'sockjs',
options: {
path: '/sockjs-node',
},
},
},
devtool: 'inline-source-map',
};
const prodConfig = {
mode: 'production',
optimization: {
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
},
devtool: 'source-map',
};
module.exports = merge(baseConfig, devMode ? devConfig : prodConfig);

8066
html/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

6
man/README.md Normal file
View File

@@ -0,0 +1,6 @@
# Building the man page
```bash
go get github.com/cpuguy83/go-md2man
go-md2man < ttyd.man.md > ttyd.1
```

353
man/ttyd.1 Normal file
View File

@@ -0,0 +1,353 @@
.nh
.TH ttyd 1 "September 2016" ttyd "User Manual"
.SH NAME
.PP
ttyd - Share your terminal over the web
.SH SYNOPSIS
.PP
\fBttyd\fP [options] <command> [<arguments...>]
.SH DESCRIPTION
.PP
ttyd is a command-line tool for sharing terminal over the web that runs in *nix and windows systems, with the following features:
.RS
.IP \(bu 2
Built on top of Libwebsockets with libuv for speed
.IP \(bu 2
Fully-featured terminal based on Xterm.js with CJK (Chinese, Japanese, Korean) and IME support
.IP \(bu 2
Graphical ZMODEM integration with lrzsz support
.IP \(bu 2
Sixel image output support
.IP \(bu 2
SSL support based on OpenSSL
.IP \(bu 2
Run any custom command with options
.IP \(bu 2
Basic authentication support and many other custom options
.IP \(bu 2
Cross platform: macOS, Linux, FreeBSD/OpenBSD, OpenWrt/LEDE, Windows
.RE
.SH OPTIONS
.PP
-p, --port
Port to listen (default: 7681, use \fB\fC0\fR for random port)
.PP
-i, --interface
Network interface to bind (eg: eth0), or UNIX domain socket path (eg: /var/run/ttyd.sock)
.PP
-U, --socket-owner
User owner of the UNIX domain socket file, when enabled (eg: user:group)
.PP
-c, --credential USER[:PASSWORD]
Credential for Basic Authentication (format: username:password)
.PP
-H, --auth-header
HTTP Header name for auth proxy, this will configure ttyd to let a HTTP reverse proxy handle authentication
.PP
-u, --uid
User id to run with
.PP
-g, --gid
Group id to run with
.PP
-s, --signal
Signal to send to the command when exit it (default: 1, SIGHUP)
.PP
-w, --cwd
Working directory to be set for the child program
.PP
-a, --url-arg
Allow client to send command line arguments in URL (eg: http://localhost:7681?arg=foo&arg=bar)
.PP
-W, --writable
Allow clients to write to the TTY (readonly by default)
.PP
-t, --client-option
Send option to client (format: key=value), repeat to add more options, see \fBCLIENT OPTIONS\fP for details
.PP
-T, --terminal-type
Terminal type to report, default: xterm-256color
.PP
-O, --check-origin
Do not allow websocket connection from different origin
.PP
-m, --max-clients
Maximum clients to support (default: 0, no limit)
.PP
-o, --once
Accept only one client and exit on disconnection
.PP
-q, --exit-no-conn
Exit on all clients disconnection
.PP
-B, --browser
Open terminal with the default system browser
.PP
-I, --index
Custom index.html path
.PP
-b, --base-path
Expected base path for requests coming from a reverse proxy (eg: /mounted/here, max length: 128)
.PP
-P, --ping-interval
Websocket ping interval(sec) (default: 5)
.PP
-f, --srv-buf-size
Maximum chunk of file (in bytes) that can be sent at once, a larger value may improve throughput (default: 4096)
.PP
-6, --ipv6
Enable IPv6 support
.PP
-S, --ssl
Enable SSL
.PP
-C, --ssl-cert
SSL certificate file path
.PP
-K, --ssl-key
SSL key file path
.PP
-A, --ssl-ca
SSL CA file path for client certificate verification
.PP
-d, --debug
Set log level (default: 7)
.PP
-v, --version
Print the version and exit
.PP
-h, --help
Print this text and exit
.SH CLIENT OPTIONS
.PP
ttyd has a mechanism to pass server side command-line arguments to the browser page which is called \fBclient options\fP:
.PP
.RS
.nf
-t, --client-option Send option to client (format: key=value), repeat to add more options
.fi
.RE
.SH Basic usage
.RS
.IP \(bu 2
\fB\fC-t rendererType=canvas\fR: use the \fB\fCcanvas\fR renderer for xterm.js (default: \fB\fCwebgl\fR)
.IP \(bu 2
\fB\fC-t disableLeaveAlert=true\fR: disable the leave page alert
.IP \(bu 2
\fB\fC-t disableResizeOverlay=true\fR: disable the terminal resize overlay
.IP \(bu 2
\fB\fC-t disableReconnect=true\fR: prevent the terminal from reconnecting on connection error/close
.IP \(bu 2
\fB\fC-t enableZmodem=true\fR: enable ZMODEM
\[la]https://en.wikipedia.org/wiki/ZMODEM\[ra] / lrzsz
\[la]https://ohse.de/uwe/software/lrzsz.html\[ra] file transfer support
.IP \(bu 2
\fB\fC-t enableTrzsz=true\fR: enable trzsz
\[la]https://trzsz.github.io\[ra] file transfer support
.IP \(bu 2
\fB\fC-t enableSixel=true\fR: enable Sixel
\[la]https://en.wikipedia.org/wiki/Sixel\[ra] image output support (Usage
\[la]https://saitoha.github.io/libsixel/\[ra])
.IP \(bu 2
\fB\fC-t closeOnDisconnect=true\fR: close the terminal on disconnection, this will disable reconnect
.IP \(bu 2
\fB\fC-t titleFixed=hello\fR: set a fixed title for the browser window
.IP \(bu 2
\fB\fC-t fontSize=20\fR: change the font size of the terminal
.IP \(bu 2
\fB\fC-t unicodeVersion=11\fR: set xterm unicode support level (default: 11, use 6 to disable unicode addon)
.IP \(bu 2
\fB\fC-t trzszDragInitTimeout=3000\fR: set the timeout in milliseconds for initializing drag and drop files to upload. (default: 3000)
.RE
.SH Advanced usage
.PP
You can use the client option to change all the settings of xterm defined in ITerminalOptions
\[la]https://xtermjs.org/docs/api/terminal/interfaces/iterminaloptions/\[ra], examples:
.RS
.IP \(bu 2
\fB\fC-t cursorStyle=bar\fR: set cursor style to \fB\fCbar\fR
.IP \(bu 2
\fB\fC-t lineHeight=1.5\fR: set line-height to \fB\fC1.5\fR
.IP \(bu 2
\fB\fC-t 'theme={"background": "green"}'\fR: set background color to \fB\fCgreen\fR
.RE
.PP
to try the example options above, run:
.PP
.RS
.nf
ttyd -t cursorStyle=bar -t lineHeight=1.5 -t 'theme={"background": "green"}' bash
.fi
.RE
.SH EXAMPLES
.PP
ttyd starts web server at port 7681 by default, you can use the -p option to change it, the command will be started with arguments as options. For example, run:
.PP
.RS
.nf
ttyd -p 8080 bash -x
.fi
.RE
.PP
Then open http://localhost:8080 with a browser, you will get a bash shell with debug mode enabled. More examples:
.RS
.IP \(bu 2
If you want to login with your system accounts on the web browser, run \fB\fCttyd login\fR\&.
.IP \(bu 2
You can even run a non-shell command like vim, try: \fB\fCttyd vim\fR, the web browser will show you a vim editor.
.IP \(bu 2
Sharing single process with multiple clients: \fB\fCttyd tmux new -A -s ttyd vim\fR, run \fB\fCtmux new -A -s ttyd\fR to connect to the tmux session from terminal.
.RE
.SH SSL how-to
.PP
Generate SSL CA and self signed server/client certificates:
.PP
.RS
.nf
# CA certificate (FQDN must be different from server/client)
openssl genrsa -out ca.key 2048
openssl req -new -x509 -days 365 -key ca.key -subj "/C=CN/ST=GD/L=SZ/O=Acme, Inc./CN=Acme Root CA" -out ca.crt
# server certificate (for multiple domains, change subjectAltName to: DNS:example.com,DNS:www.example.com)
openssl req -newkey rsa:2048 -nodes -keyout server.key -subj "/C=CN/ST=GD/L=SZ/O=Acme, Inc./CN=localhost" -out server.csr
openssl x509 -sha256 -req -extfile <(printf "subjectAltName=DNS:localhost") -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt
# client certificate (the p12/pem format may be useful for some clients)
openssl req -newkey rsa:2048 -nodes -keyout client.key -subj "/C=CN/ST=GD/L=SZ/O=Acme, Inc./CN=client" -out client.csr
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt
openssl pkcs12 -export -clcerts -in client.crt -inkey client.key -out client.p12
openssl pkcs12 -in client.p12 -out client.pem -clcerts
.fi
.RE
.PP
Then start ttyd:
.PP
.RS
.nf
ttyd --ssl --ssl-cert server.crt --ssl-key server.key --ssl-ca ca.crt bash
.fi
.RE
.PP
You may want to test the client certificate verification with \fIcurl\fP(1):
.PP
.RS
.nf
curl --insecure --cert client.p12[:password] -v https://localhost:7681
.fi
.RE
.PP
If you don't want to enable client certificate verification, remove the \fB\fC--ssl-ca\fR option.
.SH Docker and ttyd
.PP
Docker containers are jailed environments which are more secure, this is useful for protecting the host system, you may use ttyd with docker like this:
.RS
.IP \(bu 2
Sharing single docker container with multiple clients: docker run -it --rm -p 7681:7681 tsl0922/ttyd.
.IP \(bu 2
Creating new docker container for each client: ttyd docker run -it --rm ubuntu.
.RE
.SH Nginx reverse proxy
.PP
Sample config to proxy ttyd under the \fB\fC/ttyd\fR path:
.PP
.RS
.nf
location ~ ^/ttyd(.*)$ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://127.0.0.1:7681/$1;
}
.fi
.RE
.SH AUTHOR
.PP
Shuanglei Tao <tsl0922@gmail.com> Visit https://github.com/tsl0922/ttyd to get more information and report bugs.

218
man/ttyd.man.md Normal file
View File

@@ -0,0 +1,218 @@
ttyd 1 "September 2016" ttyd "User Manual"
==================================================
# NAME
ttyd - Share your terminal over the web
# SYNOPSIS
**ttyd** [options] \<command\> [\<arguments...\>]
# DESCRIPTION
ttyd is a command-line tool for sharing terminal over the web that runs in *nix and windows systems, with the following features:
- Built on top of Libwebsockets with libuv for speed
- Fully-featured terminal based on Xterm.js with CJK (Chinese, Japanese, Korean) and IME support
- Graphical ZMODEM integration with lrzsz support
- Sixel image output support
- SSL support based on OpenSSL
- Run any custom command with options
- Basic authentication support and many other custom options
- Cross platform: macOS, Linux, FreeBSD/OpenBSD, OpenWrt/LEDE, Windows
# OPTIONS
-p, --port <port>
Port to listen (default: 7681, use `0` for random port)
-i, --interface <interface>
Network interface to bind (eg: eth0), or UNIX domain socket path (eg: /var/run/ttyd.sock)
-U, --socket-owner
User owner of the UNIX domain socket file, when enabled (eg: user:group)
-c, --credential USER[:PASSWORD]
Credential for Basic Authentication (format: username:password)
-H, --auth-header <name>
HTTP Header name for auth proxy, this will configure ttyd to let a HTTP reverse proxy handle authentication
-u, --uid <uid>
User id to run with
-g, --gid <gid>
Group id to run with
-s, --signal <signal string>
Signal to send to the command when exit it (default: 1, SIGHUP)
-w, --cwd <path>
Working directory to be set for the child program
-a, --url-arg
Allow client to send command line arguments in URL (eg: http://localhost:7681?arg=foo&arg=bar)
-W, --writable
Allow clients to write to the TTY (readonly by default)
-t, --client-option <key=value>
Send option to client (format: key=value), repeat to add more options, see **CLIENT OPTIONS** for details
-T, --terminal-type
Terminal type to report, default: xterm-256color
-O, --check-origin
Do not allow websocket connection from different origin
-m, --max-clients
Maximum clients to support (default: 0, no limit)
-o, --once
Accept only one client and exit on disconnection
-q, --exit-no-conn
Exit on all clients disconnection
-B, --browser
Open terminal with the default system browser
-I, --index <index file>
Custom index.html path
-b, --base-path
Expected base path for requests coming from a reverse proxy (eg: /mounted/here, max length: 128)
-P, --ping-interval
Websocket ping interval(sec) (default: 5)
-f, --srv-buf-size
Maximum chunk of file (in bytes) that can be sent at once, a larger value may improve throughput (default: 4096)
-6, --ipv6
Enable IPv6 support
-S, --ssl
Enable SSL
-C, --ssl-cert <cert path>
SSL certificate file path
-K, --ssl-key <key path>
SSL key file path
-A, --ssl-ca <ca path>
SSL CA file path for client certificate verification
-d, --debug <level>
Set log level (default: 7)
-v, --version
Print the version and exit
-h, --help
Print this text and exit
# CLIENT OPTIONS
ttyd has a mechanism to pass server side command-line arguments to the browser page which is called **client options**:
```bash
-t, --client-option Send option to client (format: key=value), repeat to add more options
```
## Basic usage
- `-t rendererType=canvas`: use the `canvas` renderer for xterm.js (default: `webgl`)
- `-t disableLeaveAlert=true`: disable the leave page alert
- `-t disableResizeOverlay=true`: disable the terminal resize overlay
- `-t disableReconnect=true`: prevent the terminal from reconnecting on connection error/close
- `-t enableZmodem=true`: enable [ZMODEM](https://en.wikipedia.org/wiki/ZMODEM) / [lrzsz](https://ohse.de/uwe/software/lrzsz.html) file transfer support
- `-t enableTrzsz=true`: enable [trzsz](https://trzsz.github.io) file transfer support
- `-t enableSixel=true`: enable [Sixel](https://en.wikipedia.org/wiki/Sixel) image output support ([Usage](https://saitoha.github.io/libsixel/))
- `-t closeOnDisconnect=true`: close the terminal on disconnection, this will disable reconnect
- `-t titleFixed=hello`: set a fixed title for the browser window
- `-t fontSize=20`: change the font size of the terminal
- `-t unicodeVersion=11`: set xterm unicode support level (default: 11, use 6 to disable unicode addon)
- `-t trzszDragInitTimeout=3000`: set the timeout in milliseconds for initializing drag and drop files to upload. (default: 3000)
## Advanced usage
You can use the client option to change all the settings of xterm defined in [ITerminalOptions](https://xtermjs.org/docs/api/terminal/interfaces/iterminaloptions/), examples:
- `-t cursorStyle=bar`: set cursor style to `bar`
- `-t lineHeight=1.5`: set line-height to `1.5`
- `-t 'theme={"background": "green"}'`: set background color to `green`
to try the example options above, run:
```bash
ttyd -t cursorStyle=bar -t lineHeight=1.5 -t 'theme={"background": "green"}' bash
```
# EXAMPLES
ttyd starts web server at port 7681 by default, you can use the -p option to change it, the command will be started with arguments as options. For example, run:
```
ttyd -p 8080 bash -x
```
Then open http://localhost:8080 with a browser, you will get a bash shell with debug mode enabled. More examples:
- If you want to login with your system accounts on the web browser, run `ttyd login`.
- You can even run a non-shell command like vim, try: `ttyd vim`, the web browser will show you a vim editor.
- Sharing single process with multiple clients: `ttyd tmux new -A -s ttyd vim`, run `tmux new -A -s ttyd` to connect to the tmux session from terminal.
# SSL how-to
Generate SSL CA and self signed server/client certificates:
```
# CA certificate (FQDN must be different from server/client)
openssl genrsa -out ca.key 2048
openssl req -new -x509 -days 365 -key ca.key -subj "/C=CN/ST=GD/L=SZ/O=Acme, Inc./CN=Acme Root CA" -out ca.crt
# server certificate (for multiple domains, change subjectAltName to: DNS:example.com,DNS:www.example.com)
openssl req -newkey rsa:2048 -nodes -keyout server.key -subj "/C=CN/ST=GD/L=SZ/O=Acme, Inc./CN=localhost" -out server.csr
openssl x509 -sha256 -req -extfile <(printf "subjectAltName=DNS:localhost") -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt
# client certificate (the p12/pem format may be useful for some clients)
openssl req -newkey rsa:2048 -nodes -keyout client.key -subj "/C=CN/ST=GD/L=SZ/O=Acme, Inc./CN=client" -out client.csr
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt
openssl pkcs12 -export -clcerts -in client.crt -inkey client.key -out client.p12
openssl pkcs12 -in client.p12 -out client.pem -clcerts
```
Then start ttyd:
```
ttyd --ssl --ssl-cert server.crt --ssl-key server.key --ssl-ca ca.crt bash
```
You may want to test the client certificate verification with *curl*(1):
```
curl --insecure --cert client.p12[:password] -v https://localhost:7681
```
If you don't want to enable client certificate verification, remove the `--ssl-ca` option.
# Docker and ttyd
Docker containers are jailed environments which are more secure, this is useful for protecting the host system, you may use ttyd with docker like this:
- Sharing single docker container with multiple clients: docker run -it --rm -p 7681:7681 tsl0922/ttyd.
- Creating new docker container for each client: ttyd docker run -it --rm ubuntu.
# Nginx reverse proxy
Sample config to proxy ttyd under the `/ttyd` path:
```nginx
location ~ ^/ttyd(.*)$ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://127.0.0.1:7681/$1;
}
```
# AUTHOR
Shuanglei Tao \<tsl0922@gmail.com\> Visit https://github.com/tsl0922/ttyd to get more information and report bugs.

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "ttyd",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

BIN
screenshot.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

191
scripts/cross-build.sh Executable file
View File

@@ -0,0 +1,191 @@
#!/bin/bash
#
# Example:
# env BUILD_TARGET=mips ./scripts/cross-build.sh
#
set -eo pipefail
CROSS_ROOT="${CROSS_ROOT:-/opt/cross}"
STAGE_ROOT="${STAGE_ROOT:-/opt/stage}"
BUILD_ROOT="${BUILD_ROOT:-/opt/build}"
BUILD_TARGET="${BUILD_TARGET:-x86_64}"
ZLIB_VERSION="${ZLIB_VERSION:-1.3.1}"
JSON_C_VERSION="${JSON_C_VERSION:-0.17}"
MBEDTLS_VERSION="${MBEDTLS_VERSION:-2.28.5}"
LIBUV_VERSION="${LIBUV_VERSION:-1.44.2}"
LIBWEBSOCKETS_VERSION="${LIBWEBSOCKETS_VERSION:-4.3.3}"
build_zlib() {
echo "=== Building zlib-${ZLIB_VERSION} (${TARGET})..."
curl -fSsLo- "https://zlib.net/zlib-${ZLIB_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}"/zlib-"${ZLIB_VERSION}"
env CHOST="${TARGET}" ./configure --static --archs="-fPIC" --prefix="${STAGE_DIR}"
make -j"$(nproc)" install
popd
}
build_json-c() {
echo "=== Building json-c-${JSON_C_VERSION} (${TARGET})..."
curl -fSsLo- "https://s3.amazonaws.com/json-c_releases/releases/json-c-${JSON_C_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}/json-c-${JSON_C_VERSION}"
rm -rf build && mkdir -p build && cd build
cmake -DCMAKE_TOOLCHAIN_FILE="${BUILD_DIR}/cross-${TARGET}.cmake" \
-DCMAKE_BUILD_TYPE=RELEASE \
-DCMAKE_INSTALL_PREFIX="${STAGE_DIR}" \
-DBUILD_SHARED_LIBS=OFF \
-DBUILD_TESTING=OFF \
-DDISABLE_THREAD_LOCAL_STORAGE=ON \
..
make -j"$(nproc)" install
popd
}
build_mbedtls() {
echo "=== Building mbedtls-${MBEDTLS_VERSION} (${TARGET})..."
curl -fSsLo- "https://github.com/ARMmbed/mbedtls/archive/v${MBEDTLS_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}/mbedtls-${MBEDTLS_VERSION}"
rm -rf build && mkdir -p build && cd build
cmake -DCMAKE_TOOLCHAIN_FILE="${BUILD_DIR}/cross-${TARGET}.cmake" \
-DCMAKE_BUILD_TYPE=RELEASE \
-DCMAKE_INSTALL_PREFIX="${STAGE_DIR}" \
-DENABLE_TESTING=OFF \
..
make -j"$(nproc)" install
popd
}
build_libuv() {
echo "=== Building libuv-${LIBUV_VERSION} (${TARGET})..."
curl -fSsLo- "https://dist.libuv.org/dist/v${LIBUV_VERSION}/libuv-v${LIBUV_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}/libuv-v${LIBUV_VERSION}"
./autogen.sh
env CFLAGS=-fPIC ./configure --disable-shared --enable-static --prefix="${STAGE_DIR}" --host="${TARGET}"
make -j"$(nproc)" install
popd
}
install_cmake_cross_file() {
cat << EOF > "${BUILD_DIR}/cross-${TARGET}.cmake"
SET(CMAKE_SYSTEM_NAME $1)
set(CMAKE_C_COMPILER "${TARGET}-gcc")
set(CMAKE_CXX_COMPILER "${TARGET}-g++")
set(CMAKE_FIND_ROOT_PATH "${STAGE_DIR}")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(OPENSSL_USE_STATIC_LIBS TRUE)
EOF
}
build_libwebsockets() {
echo "=== Building libwebsockets-${LIBWEBSOCKETS_VERSION} (${TARGET})..."
curl -fSsLo- "https://github.com/warmcat/libwebsockets/archive/v${LIBWEBSOCKETS_VERSION}.tar.gz" | tar xz -C "${BUILD_DIR}"
pushd "${BUILD_DIR}/libwebsockets-${LIBWEBSOCKETS_VERSION}"
sed -i 's/ websockets_shared//g' cmake/libwebsockets-config.cmake.in
sed -i 's/ OR PC_OPENSSL_FOUND//g' lib/tls/CMakeLists.txt
sed -i '/PC_OPENSSL/d' lib/tls/CMakeLists.txt
rm -rf build && mkdir -p build && cd build
cmake -DCMAKE_TOOLCHAIN_FILE="${BUILD_DIR}/cross-${TARGET}.cmake" \
-DCMAKE_BUILD_TYPE=RELEASE \
-DCMAKE_INSTALL_PREFIX="${STAGE_DIR}" \
-DCMAKE_FIND_LIBRARY_SUFFIXES=".a" \
-DCMAKE_EXE_LINKER_FLAGS="-static" \
-DLWS_WITHOUT_TESTAPPS=ON \
-DLWS_WITH_MBEDTLS=ON \
-DLWS_WITH_LIBUV=ON \
-DLWS_STATIC_PIC=ON \
-DLWS_WITH_SHARED=OFF \
-DLWS_UNIX_SOCK=ON \
-DLWS_IPV6=ON \
-DLWS_ROLE_RAW_FILE=OFF \
-DLWS_WITH_HTTP2=ON \
-DLWS_WITH_HTTP_BASIC_AUTH=OFF \
-DLWS_WITH_UDP=OFF \
-DLWS_WITHOUT_CLIENT=ON \
-DLWS_WITHOUT_EXTENSIONS=OFF \
-DLWS_WITH_LEJP=OFF \
-DLWS_WITH_LEJP_CONF=OFF \
-DLWS_WITH_LWSAC=OFF \
-DLWS_WITH_SEQUENCER=OFF \
..
make -j"$(nproc)" install
popd
}
build_ttyd() {
echo "=== Building ttyd (${TARGET})..."
rm -rf build && mkdir -p build && cd build
cmake -DCMAKE_TOOLCHAIN_FILE="${BUILD_DIR}/cross-${TARGET}.cmake" \
-DCMAKE_INSTALL_PREFIX="${STAGE_DIR}" \
-DCMAKE_FIND_LIBRARY_SUFFIXES=".a" \
-DCMAKE_C_FLAGS="-Os -ffunction-sections -fdata-sections -fno-unwind-tables -fno-asynchronous-unwind-tables -flto" \
-DCMAKE_EXE_LINKER_FLAGS="-static -no-pie -Wl,-s -Wl,-Bsymbolic -Wl,--gc-sections" \
-DCMAKE_BUILD_TYPE=RELEASE \
..
make install
}
build() {
TARGET="$1"
ALIAS="$2"
STAGE_DIR="${STAGE_ROOT}/${TARGET}"
BUILD_DIR="${BUILD_ROOT}/${TARGET}"
MUSL_CC_URL="https://github.com/tsl0922/musl-toolchains/releases/download/2021-11-23"
COMPONENTS="1"
SYSTEM="Linux"
if [ "$ALIAS" = "win32" ]; then
COMPONENTS=2
SYSTEM="Windows"
fi
echo "=== Installing toolchain ${ALIAS} (${TARGET})..."
mkdir -p "${CROSS_ROOT}" && export PATH="${PATH}:${CROSS_ROOT}/bin"
curl -fSsLo- "${MUSL_CC_URL}/${TARGET}-cross.tgz" | tar xz -C "${CROSS_ROOT}" --strip-components=${COMPONENTS}
echo "=== Building target ${ALIAS} (${TARGET})..."
rm -rf "${STAGE_DIR}" "${BUILD_DIR}"
mkdir -p "${STAGE_DIR}" "${BUILD_DIR}"
export PKG_CONFIG_PATH="${STAGE_DIR}/lib/pkgconfig"
install_cmake_cross_file ${SYSTEM}
build_zlib
build_json-c
build_libuv
build_mbedtls
build_libwebsockets
build_ttyd
}
case ${BUILD_TARGET} in
amd64) BUILD_TARGET="x86_64" ;;
arm64) BUILD_TARGET="aarch64" ;;
armv7) BUILD_TARGET="armv7l" ;;
esac
case ${BUILD_TARGET} in
i686|x86_64|aarch64|mips|mipsel|mips64|mips64el|s390x)
build "${BUILD_TARGET}-linux-musl" "${BUILD_TARGET}"
;;
arm)
build "${BUILD_TARGET}-linux-musleabi" "${BUILD_TARGET}"
;;
armhf)
build arm-linux-musleabihf "${BUILD_TARGET}"
;;
armv7l)
build armv7l-linux-musleabihf "${BUILD_TARGET}"
;;
win32)
build x86_64-w64-mingw32 "${BUILD_TARGET}"
;;
*)
echo "unknown cross target: ${BUILD_TARGET}" && exit 1
esac

27
scripts/mingw-build.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -eo pipefail
build_libwebsockets() {
svn co https://github.com/msys2/MINGW-packages/trunk/mingw-w64-libwebsockets
sed -i 's/openssl/mbedtls/' mingw-w64-libwebsockets/PKGBUILD
sed -i '/-DCMAKE_INSTALL_PREFIX=${MINGW_PREFIX}/a \ -DLWS_WITH_MBEDTLS=ON \\' mingw-w64-libwebsockets/PKGBUILD
sed -i '/-DCMAKE_INSTALL_PREFIX=${MINGW_PREFIX}/a \ -DLWS_WITH_LIBUV=ON \\' mingw-w64-libwebsockets/PKGBUILD
pushd mingw-w64-libwebsockets
makepkg-mingw --cleanbuild --syncdeps --force --noconfirm
pacman -U *.pkg.tar.zst --noconfirm
popd
}
build_libwebsockets
# workaround for the lib name change
cp ${MINGW_PREFIX}/lib/libuv_a.a ${MINGW_PREFIX}/lib/libuv.a
rm -rf build && mkdir -p build && cd build
cmake -DCMAKE_BUILD_TYPE=RELEASE \
-DCMAKE_FIND_LIBRARY_SUFFIXES=".a" \
-DCMAKE_C_FLAGS="-Os -ffunction-sections -fdata-sections -fno-unwind-tables -fno-asynchronous-unwind-tables -flto" \
-DCMAKE_EXE_LINKER_FLAGS="-static -no-pie -Wl,-s -Wl,-Bsymbolic -Wl,--gc-sections" \
..
cmake --build .

49
snap/snapcraft.yaml Normal file
View File

@@ -0,0 +1,49 @@
name: ttyd
adopt-info: ttyd
summary: Share your terminal over the web
description: |
ttyd is a simple command-line tool for sharing terminal over the web
grade: stable
confinement: classic
base: core20
compression: lzo
license: MIT
assumes:
- command-chain
apps:
ttyd:
command: usr/bin/ttyd
command-chain:
- bin/homeishome-launch
parts:
ttyd:
source: https://github.com/tsl0922/ttyd
source-type: git
plugin: cmake
cmake-parameters:
- -DCMAKE_INSTALL_PREFIX=/usr
build-environment:
- LDFLAGS: "-pthread"
override-pull: |
snapcraftctl pull
snapcraftctl set-version "$(git describe --tags | sed 's/^v//' | cut -d "-" -f1)"
build-packages:
- build-essential
- libjson-c-dev
- libwebsockets-dev
stage-packages:
- libjson-c4
- libwebsockets15
homeishome-launch:
plugin: nil
stage-snaps:
- homeishome-launch

16460
src/html.h generated Normal file

File diff suppressed because it is too large Load Diff

240
src/http.c Normal file
View File

@@ -0,0 +1,240 @@
#include <libwebsockets.h>
#include <string.h>
#include <zlib.h>
#include "html.h"
#include "server.h"
#include "utils.h"
enum { AUTH_OK, AUTH_FAIL, AUTH_ERROR };
static char *html_cache = NULL;
static size_t html_cache_len = 0;
static int send_unauthorized(struct lws *wsi, unsigned int code, enum lws_token_indexes header) {
unsigned char buffer[1024 + LWS_PRE], *p, *end;
p = buffer + LWS_PRE;
end = p + sizeof(buffer) - LWS_PRE;
if (lws_add_http_header_status(wsi, code, &p, end) ||
lws_add_http_header_by_token(wsi, header, (unsigned char *)"Basic realm=\"ttyd\"", 18, &p, end) ||
lws_add_http_header_content_length(wsi, 0, &p, end) || lws_finalize_http_header(wsi, &p, end) ||
lws_write(wsi, buffer + LWS_PRE, p - (buffer + LWS_PRE), LWS_WRITE_HTTP_HEADERS) < 0)
return AUTH_FAIL;
return lws_http_transaction_completed(wsi) ? AUTH_FAIL : AUTH_ERROR;
}
static int check_auth(struct lws *wsi, struct pss_http *pss) {
if (server->auth_header != NULL) {
if (lws_hdr_custom_length(wsi, server->auth_header, strlen(server->auth_header)) > 0) return AUTH_OK;
return send_unauthorized(wsi, HTTP_STATUS_PROXY_AUTH_REQUIRED, WSI_TOKEN_HTTP_PROXY_AUTHENTICATE);
}
if(server->credential != NULL) {
char buf[256];
int len = lws_hdr_copy(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_AUTHORIZATION);
if (len >= 7 && strstr(buf, "Basic ")) {
if (!strcmp(buf + 6, server->credential)) return AUTH_OK;
}
return send_unauthorized(wsi, HTTP_STATUS_UNAUTHORIZED, WSI_TOKEN_HTTP_WWW_AUTHENTICATE);
}
return AUTH_OK;
}
static bool accept_gzip(struct lws *wsi) {
char buf[256];
int len = lws_hdr_copy(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_ACCEPT_ENCODING);
return len > 0 && strstr(buf, "gzip") != NULL;
}
static bool uncompress_html(char **output, size_t *output_len) {
if (html_cache == NULL || html_cache_len == 0) {
z_stream stream;
memset(&stream, 0, sizeof(stream));
if (inflateInit2(&stream, 16 + 15) != Z_OK) return false;
html_cache_len = index_html_size;
html_cache = xmalloc(html_cache_len);
stream.avail_in = index_html_len;
stream.avail_out = html_cache_len;
stream.next_in = (void *)index_html;
stream.next_out = (void *)html_cache;
int ret = inflate(&stream, Z_SYNC_FLUSH);
inflateEnd(&stream);
if (ret != Z_STREAM_END) {
free(html_cache);
html_cache = NULL;
html_cache_len = 0;
return false;
}
}
*output = html_cache;
*output_len = html_cache_len;
return true;
}
static void pss_buffer_free(struct pss_http *pss) {
if (pss->buffer != (char *)index_html && pss->buffer != html_cache) free(pss->buffer);
}
static void access_log(struct lws *wsi, const char *path) {
char rip[50];
lws_get_peer_simple(lws_get_network_wsi(wsi), rip, sizeof(rip));
lwsl_notice("HTTP %s - %s\n", path, rip);
}
int callback_http(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) {
struct pss_http *pss = (struct pss_http *)user;
unsigned char buffer[4096 + LWS_PRE], *p, *end;
char buf[256];
bool done = false;
switch (reason) {
case LWS_CALLBACK_HTTP:
access_log(wsi, (const char *)in);
snprintf(pss->path, sizeof(pss->path), "%s", (const char *)in);
switch (check_auth(wsi, pss)) {
case AUTH_OK:
break;
case AUTH_FAIL:
return 0;
case AUTH_ERROR:
default:
return 1;
}
p = buffer + LWS_PRE;
end = p + sizeof(buffer) - LWS_PRE;
if (strcmp(pss->path, endpoints.token) == 0) {
const char *credential = server->credential != NULL ? server->credential : "";
size_t n = sprintf(buf, "{\"token\": \"%s\"}", credential);
if (lws_add_http_header_status(wsi, HTTP_STATUS_OK, &p, end) ||
lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE,
(unsigned char *)"application/json;charset=utf-8", 30, &p, end) ||
lws_add_http_header_content_length(wsi, (unsigned long)n, &p, end) ||
lws_finalize_http_header(wsi, &p, end) ||
lws_write(wsi, buffer + LWS_PRE, p - (buffer + LWS_PRE), LWS_WRITE_HTTP_HEADERS) < 0)
return 1;
pss->buffer = pss->ptr = strdup(buf);
pss->len = n;
lws_callback_on_writable(wsi);
break;
}
// redirects `/base-path` to `/base-path/`
if (strcmp(pss->path, endpoints.parent) == 0) {
if (lws_add_http_header_status(wsi, HTTP_STATUS_FOUND, &p, end) ||
lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_LOCATION, (unsigned char *)endpoints.index,
(int)strlen(endpoints.index), &p, end) ||
lws_add_http_header_content_length(wsi, 0, &p, end) || lws_finalize_http_header(wsi, &p, end) ||
lws_write(wsi, buffer + LWS_PRE, p - (buffer + LWS_PRE), LWS_WRITE_HTTP_HEADERS) < 0)
return 1;
goto try_to_reuse;
}
if (strcmp(pss->path, endpoints.index) != 0) {
lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL);
goto try_to_reuse;
}
const char *content_type = "text/html";
if (server->index != NULL) {
int n = lws_serve_http_file(wsi, server->index, content_type, NULL, 0);
if (n < 0 || (n > 0 && lws_http_transaction_completed(wsi))) return 1;
} else {
char *output = (char *)index_html;
size_t output_len = index_html_len;
if (lws_add_http_header_status(wsi, HTTP_STATUS_OK, &p, end) ||
lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, (const unsigned char *)content_type, 9, &p,
end))
return 1;
#ifdef LWS_WITH_HTTP_STREAM_COMPRESSION
if (!uncompress_html(&output, &output_len)) return 1;
#else
if (accept_gzip(wsi)) {
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_ENCODING, (unsigned char *)"gzip", 4, &p, end))
return 1;
} else {
if (!uncompress_html(&output, &output_len)) return 1;
}
#endif
if (lws_add_http_header_content_length(wsi, (unsigned long)output_len, &p, end) ||
lws_finalize_http_header(wsi, &p, end) ||
lws_write(wsi, buffer + LWS_PRE, p - (buffer + LWS_PRE), LWS_WRITE_HTTP_HEADERS) < 0)
return 1;
pss->buffer = pss->ptr = output;
pss->len = output_len;
lws_callback_on_writable(wsi);
}
break;
case LWS_CALLBACK_HTTP_WRITEABLE:
if (!pss->buffer || pss->len == 0) {
goto try_to_reuse;
}
do {
int n = sizeof(buffer) - LWS_PRE;
int m = lws_get_peer_write_allowance(wsi);
if (m == 0) {
lws_callback_on_writable(wsi);
return 0;
} else if (m != -1 && m < n) {
n = m;
}
if (pss->ptr + n > pss->buffer + pss->len) {
n = (int)(pss->len - (pss->ptr - pss->buffer));
done = true;
}
memcpy(buffer + LWS_PRE, pss->ptr, n);
pss->ptr += n;
if (lws_write_http(wsi, buffer + LWS_PRE, (size_t)n) < n) {
pss_buffer_free(pss);
return -1;
}
} while (!lws_send_pipe_choked(wsi) && !done);
if (!done && pss->ptr < pss->buffer + pss->len) {
lws_callback_on_writable(wsi);
break;
}
pss_buffer_free(pss);
goto try_to_reuse;
case LWS_CALLBACK_HTTP_FILE_COMPLETION:
goto try_to_reuse;
#if (defined(LWS_OPENSSL_SUPPORT) || defined(LWS_WITH_TLS)) && !defined(LWS_WITH_MBEDTLS)
case LWS_CALLBACK_OPENSSL_PERFORM_CLIENT_CERT_VERIFICATION:
if (!len || (SSL_get_verify_result((SSL *)in) != X509_V_OK)) {
int err = X509_STORE_CTX_get_error((X509_STORE_CTX *)user);
int depth = X509_STORE_CTX_get_error_depth((X509_STORE_CTX *)user);
const char *msg = X509_verify_cert_error_string(err);
lwsl_err("client certificate verification error: %s (%d), depth: %d\n", msg, err, depth);
return 1;
}
break;
#endif
default:
break;
}
return 0;
/* if we're on HTTP1.1 or 2.0, will keep the idle connection alive */
try_to_reuse:
if (lws_http_transaction_completed(wsi)) return -1;
return 0;
}

395
src/protocol.c Normal file
View File

@@ -0,0 +1,395 @@
#include <errno.h>
#include <json.h>
#include <libwebsockets.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "pty.h"
#include "server.h"
#include "utils.h"
// initial message list
static char initial_cmds[] = {SET_WINDOW_TITLE, SET_PREFERENCES};
static int send_initial_message(struct lws *wsi, int index) {
unsigned char message[LWS_PRE + 1 + 4096];
unsigned char *p = &message[LWS_PRE];
char buffer[128];
int n = 0;
char cmd = initial_cmds[index];
switch (cmd) {
case SET_WINDOW_TITLE:
gethostname(buffer, sizeof(buffer) - 1);
n = sprintf((char *)p, "%c%s (%s)", cmd, server->command, buffer);
break;
case SET_PREFERENCES:
n = sprintf((char *)p, "%c%s", cmd, server->prefs_json);
break;
default:
break;
}
return lws_write(wsi, p, (size_t)n, LWS_WRITE_BINARY);
}
static json_object *parse_window_size(const char *buf, size_t len, uint16_t *cols, uint16_t *rows) {
json_tokener *tok = json_tokener_new();
json_object *obj = json_tokener_parse_ex(tok, buf, len);
struct json_object *o = NULL;
if (json_object_object_get_ex(obj, "columns", &o)) *cols = (uint16_t)json_object_get_int(o);
if (json_object_object_get_ex(obj, "rows", &o)) *rows = (uint16_t)json_object_get_int(o);
json_tokener_free(tok);
return obj;
}
static bool check_host_origin(struct lws *wsi) {
char buf[256];
memset(buf, 0, sizeof(buf));
int len = lws_hdr_copy(wsi, buf, (int)sizeof(buf), WSI_TOKEN_ORIGIN);
if (len <= 0) return false;
const char *prot, *address, *path;
int port;
if (lws_parse_uri(buf, &prot, &address, &port, &path)) return false;
if (port == 80 || port == 443) {
sprintf(buf, "%s", address);
} else {
sprintf(buf, "%s:%d", address, port);
}
char host_buf[256];
memset(host_buf, 0, sizeof(host_buf));
len = lws_hdr_copy(wsi, host_buf, (int)sizeof(host_buf), WSI_TOKEN_HOST);
return len > 0 && strcasecmp(buf, host_buf) == 0;
}
static pty_ctx_t *pty_ctx_init(struct pss_tty *pss) {
pty_ctx_t *ctx = xmalloc(sizeof(pty_ctx_t));
ctx->pss = pss;
ctx->ws_closed = false;
return ctx;
}
static void pty_ctx_free(pty_ctx_t *ctx) { free(ctx); }
static void process_read_cb(pty_process *process, pty_buf_t *buf, bool eof) {
pty_ctx_t *ctx = (pty_ctx_t *)process->ctx;
if (ctx->ws_closed) {
pty_buf_free(buf);
return;
}
if (eof && !process_running(process))
ctx->pss->lws_close_status = process->exit_code == 0 ? 1000 : 1006;
else
ctx->pss->pty_buf = buf;
lws_callback_on_writable(ctx->pss->wsi);
}
static void process_exit_cb(pty_process *process) {
pty_ctx_t *ctx = (pty_ctx_t *)process->ctx;
if (ctx->ws_closed) {
lwsl_notice("process killed with signal %d, pid: %d\n", process->exit_signal, process->pid);
goto done;
}
lwsl_notice("process exited with code %d, pid: %d\n", process->exit_code, process->pid);
ctx->pss->process = NULL;
ctx->pss->lws_close_status = process->exit_code == 0 ? 1000 : 1006;
lws_callback_on_writable(ctx->pss->wsi);
done:
pty_ctx_free(ctx);
}
static char **build_args(struct pss_tty *pss) {
int i, n = 0;
char **argv = xmalloc((server->argc + pss->argc + 1) * sizeof(char *));
for (i = 0; i < server->argc; i++) {
argv[n++] = server->argv[i];
}
for (i = 0; i < pss->argc; i++) {
argv[n++] = pss->args[i];
}
argv[n] = NULL;
return argv;
}
static char **build_env(struct pss_tty *pss) {
int i = 0, n = 2;
char **envp = xmalloc(n * sizeof(char *));
// TERM
envp[i] = xmalloc(36);
snprintf(envp[i], 36, "TERM=%s", server->terminal_type);
i++;
// TTYD_USER
if (strlen(pss->user) > 0) {
envp = xrealloc(envp, (++n) * sizeof(char *));
envp[i] = xmalloc(40);
snprintf(envp[i], 40, "TTYD_USER=%s", pss->user);
i++;
}
envp[i] = NULL;
return envp;
}
static bool spawn_process(struct pss_tty *pss, uint16_t columns, uint16_t rows) {
pty_process *process = process_init((void *)pty_ctx_init(pss), server->loop, build_args(pss), build_env(pss));
if (server->cwd != NULL) process->cwd = strdup(server->cwd);
if (columns > 0) process->columns = columns;
if (rows > 0) process->rows = rows;
if (pty_spawn(process, process_read_cb, process_exit_cb) != 0) {
lwsl_err("pty_spawn: %d (%s)\n", errno, strerror(errno));
process_free(process);
return false;
}
lwsl_notice("started process, pid: %d\n", process->pid);
pss->process = process;
lws_callback_on_writable(pss->wsi);
return true;
}
static void wsi_output(struct lws *wsi, pty_buf_t *buf) {
if (buf == NULL) return;
char *message = xmalloc(LWS_PRE + 1 + buf->len);
char *ptr = message + LWS_PRE;
*ptr = OUTPUT;
memcpy(ptr + 1, buf->base, buf->len);
size_t n = buf->len + 1;
if (lws_write(wsi, (unsigned char *)ptr, n, LWS_WRITE_BINARY) < n) {
lwsl_err("write OUTPUT to WS\n");
}
free(message);
}
static bool check_auth(struct lws *wsi, struct pss_tty *pss) {
if (server->auth_header != NULL) {
return lws_hdr_custom_copy(wsi, pss->user, sizeof(pss->user), server->auth_header, strlen(server->auth_header)) > 0;
}
if (server->credential != NULL) {
char buf[256];
size_t n = lws_hdr_copy(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_AUTHORIZATION);
return n >= 7 && strstr(buf, "Basic ") && !strcmp(buf + 6, server->credential);
}
return true;
}
int callback_tty(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) {
struct pss_tty *pss = (struct pss_tty *)user;
char buf[256];
size_t n = 0;
switch (reason) {
case LWS_CALLBACK_FILTER_PROTOCOL_CONNECTION:
if (server->once && server->client_count > 0) {
lwsl_warn("refuse to serve WS client due to the --once option.\n");
return 1;
}
if (server->max_clients > 0 && server->client_count == server->max_clients) {
lwsl_warn("refuse to serve WS client due to the --max-clients option.\n");
return 1;
}
if (!check_auth(wsi, pss)) return 1;
n = lws_hdr_copy(wsi, pss->path, sizeof(pss->path), WSI_TOKEN_GET_URI);
#if defined(LWS_ROLE_H2)
if (n <= 0) n = lws_hdr_copy(wsi, pss->path, sizeof(pss->path), WSI_TOKEN_HTTP_COLON_PATH);
#endif
if (strncmp(pss->path, endpoints.ws, n) != 0) {
lwsl_warn("refuse to serve WS client for illegal ws path: %s\n", pss->path);
return 1;
}
if (server->check_origin && !check_host_origin(wsi)) {
lwsl_warn(
"refuse to serve WS client from different origin due to the "
"--check-origin option.\n");
return 1;
}
break;
case LWS_CALLBACK_ESTABLISHED:
pss->initialized = false;
pss->authenticated = false;
pss->wsi = wsi;
pss->lws_close_status = LWS_CLOSE_STATUS_NOSTATUS;
if (server->url_arg) {
while (lws_hdr_copy_fragment(wsi, buf, sizeof(buf), WSI_TOKEN_HTTP_URI_ARGS, n++) > 0) {
if (strncmp(buf, "arg=", 4) == 0) {
pss->args = xrealloc(pss->args, (pss->argc + 1) * sizeof(char *));
pss->args[pss->argc] = strdup(&buf[4]);
pss->argc++;
}
}
}
server->client_count++;
lws_get_peer_simple(lws_get_network_wsi(wsi), pss->address, sizeof(pss->address));
lwsl_notice("WS %s - %s, clients: %d\n", pss->path, pss->address, server->client_count);
break;
case LWS_CALLBACK_SERVER_WRITEABLE:
if (!pss->initialized) {
if (pss->initial_cmd_index == sizeof(initial_cmds)) {
pss->initialized = true;
pty_resume(pss->process);
break;
}
if (send_initial_message(wsi, pss->initial_cmd_index) < 0) {
lwsl_err("failed to send initial message, index: %d\n", pss->initial_cmd_index);
lws_close_reason(wsi, LWS_CLOSE_STATUS_UNEXPECTED_CONDITION, NULL, 0);
return -1;
}
pss->initial_cmd_index++;
lws_callback_on_writable(wsi);
break;
}
if (pss->lws_close_status > LWS_CLOSE_STATUS_NOSTATUS) {
lws_close_reason(wsi, pss->lws_close_status, NULL, 0);
return 1;
}
if (pss->pty_buf != NULL) {
wsi_output(wsi, pss->pty_buf);
pty_buf_free(pss->pty_buf);
pss->pty_buf = NULL;
pty_resume(pss->process);
}
break;
case LWS_CALLBACK_RECEIVE:
if (pss->buffer == NULL) {
pss->buffer = xmalloc(len);
pss->len = len;
memcpy(pss->buffer, in, len);
} else {
pss->buffer = xrealloc(pss->buffer, pss->len + len);
memcpy(pss->buffer + pss->len, in, len);
pss->len += len;
}
const char command = pss->buffer[0];
// check auth
if (server->credential != NULL && !pss->authenticated && command != JSON_DATA) {
lwsl_warn("WS client not authenticated\n");
return 1;
}
// check if there are more fragmented messages
if (lws_remaining_packet_payload(wsi) > 0 || !lws_is_final_fragment(wsi)) {
return 0;
}
switch (command) {
case INPUT:
if (!server->writable) break;
int err = pty_write(pss->process, pty_buf_init(pss->buffer + 1, pss->len - 1));
if (err) {
lwsl_err("uv_write: %s (%s)\n", uv_err_name(err), uv_strerror(err));
return -1;
}
break;
case RESIZE_TERMINAL:
if (pss->process == NULL) break;
json_object_put(
parse_window_size(pss->buffer + 1, pss->len - 1, &pss->process->columns, &pss->process->rows));
pty_resize(pss->process);
break;
case PAUSE:
pty_pause(pss->process);
break;
case RESUME:
pty_resume(pss->process);
break;
case JSON_DATA:
if (pss->process != NULL) break;
uint16_t columns = 0;
uint16_t rows = 0;
json_object *obj = parse_window_size(pss->buffer, pss->len, &columns, &rows);
if (server->credential != NULL) {
struct json_object *o = NULL;
if (json_object_object_get_ex(obj, "AuthToken", &o)) {
const char *token = json_object_get_string(o);
if (token != NULL && !strcmp(token, server->credential))
pss->authenticated = true;
else
lwsl_warn("WS authentication failed with token: %s\n", token);
}
if (!pss->authenticated) {
json_object_put(obj);
lws_close_reason(wsi, LWS_CLOSE_STATUS_POLICY_VIOLATION, NULL, 0);
return -1;
}
}
json_object_put(obj);
if (!spawn_process(pss, columns, rows)) return 1;
break;
default:
lwsl_warn("ignored unknown message type: %c\n", command);
break;
}
if (pss->buffer != NULL) {
free(pss->buffer);
pss->buffer = NULL;
}
break;
case LWS_CALLBACK_CLOSED:
if (pss->wsi == NULL) break;
server->client_count--;
lwsl_notice("WS closed from %s, clients: %d\n", pss->address, server->client_count);
if (pss->buffer != NULL) free(pss->buffer);
if (pss->pty_buf != NULL) pty_buf_free(pss->pty_buf);
for (int i = 0; i < pss->argc; i++) {
free(pss->args[i]);
}
if (pss->process != NULL) {
((pty_ctx_t *)pss->process->ctx)->ws_closed = true;
if (process_running(pss->process)) {
pty_pause(pss->process);
lwsl_notice("killing process, pid: %d\n", pss->process->pid);
pty_kill(pss->process, server->sig_code);
}
}
if ((server->once || server->exit_no_conn) && server->client_count == 0) {
lwsl_notice("exiting due to the --once/--exit-no-conn option.\n");
force_exit = true;
lws_cancel_service(context);
exit(0);
}
break;
default:
break;
}
return 0;
}

485
src/pty.c Normal file
View File

@@ -0,0 +1,485 @@
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#ifndef _WIN32
#include <sys/ioctl.h>
#include <sys/wait.h>
#if defined(__OpenBSD__) || defined(__APPLE__)
#include <util.h>
#elif defined(__FreeBSD__)
#include <libutil.h>
#else
#include <pty.h>
#endif
#if defined(__APPLE__)
#include <crt_externs.h>
#define environ (*_NSGetEnviron())
#else
extern char **environ;
#endif
#endif
#include "pty.h"
#include "utils.h"
#ifdef _WIN32
HRESULT (WINAPI *pCreatePseudoConsole)(COORD, HANDLE, HANDLE, DWORD, HPCON *);
HRESULT (WINAPI *pResizePseudoConsole)(HPCON, COORD);
void (WINAPI *pClosePseudoConsole)(HPCON);
#endif
static void alloc_cb(uv_handle_t *unused, size_t suggested_size, uv_buf_t *buf) {
buf->base = xmalloc(suggested_size);
buf->len = suggested_size;
}
static void close_cb(uv_handle_t *handle) { free(handle); }
static void async_free_cb(uv_handle_t *handle) {
free((uv_async_t *) handle -> data);
}
pty_buf_t *pty_buf_init(char *base, size_t len) {
pty_buf_t *buf = xmalloc(sizeof(pty_buf_t));
buf->base = xmalloc(len);
memcpy(buf->base, base, len);
buf->len = len;
return buf;
}
void pty_buf_free(pty_buf_t *buf) {
if (buf == NULL) return;
if (buf->base != NULL) free(buf->base);
free(buf);
}
static void read_cb(uv_stream_t *stream, ssize_t n, const uv_buf_t *buf) {
uv_read_stop(stream);
pty_process *process = (pty_process *) stream->data;
if (n <= 0) {
if (n == UV_ENOBUFS || n == 0) return;
process->read_cb(process, NULL, true);
goto done;
}
process->read_cb(process, pty_buf_init(buf->base, (size_t) n), false);
done:
free(buf->base);
}
static void write_cb(uv_write_t *req, int unused) {
pty_buf_t *buf = (pty_buf_t *) req->data;
pty_buf_free(buf);
free(req);
}
pty_process *process_init(void *ctx, uv_loop_t *loop, char *argv[], char *envp[]) {
pty_process *process = xmalloc(sizeof(pty_process));
memset(process, 0, sizeof(pty_process));
process->ctx = ctx;
process->loop = loop;
process->argv = argv;
process->envp = envp;
process->columns = 80;
process->rows = 24;
process->exit_code = -1;
return process;
}
bool process_running(pty_process *process) {
return process != NULL && process->pid > 0 && uv_kill(process->pid, 0) == 0;
}
void process_free(pty_process *process) {
if (process == NULL) return;
#ifdef _WIN32
if (process->si.lpAttributeList != NULL) {
DeleteProcThreadAttributeList(process->si.lpAttributeList);
free(process->si.lpAttributeList);
}
if (process->pty != NULL) pClosePseudoConsole(process->pty);
if (process->handle != NULL) CloseHandle(process->handle);
#else
close(process->pty);
uv_thread_join(&process->tid);
#endif
if (process->in != NULL) uv_close((uv_handle_t *) process->in, close_cb);
if (process->out != NULL) uv_close((uv_handle_t *) process->out, close_cb);
if (process->argv != NULL) free(process->argv);
if (process->cwd != NULL) free(process->cwd);
char **p = process->envp;
for (; *p; p++) free(*p);
free(process->envp);
}
void pty_pause(pty_process *process) {
if (process == NULL) return;
if (process->paused) return;
uv_read_stop((uv_stream_t *) process->out);
}
void pty_resume(pty_process *process) {
if (process == NULL) return;
if (!process->paused) return;
process->out->data = process;
uv_read_start((uv_stream_t *) process->out, alloc_cb, read_cb);
}
int pty_write(pty_process *process, pty_buf_t *buf) {
if (process == NULL) {
pty_buf_free(buf);
return UV_ESRCH;
}
uv_buf_t b = uv_buf_init(buf->base, buf->len);
uv_write_t *req = xmalloc(sizeof(uv_write_t));
req->data = buf;
return uv_write(req, (uv_stream_t *) process->in, &b, 1, write_cb);
}
bool pty_resize(pty_process *process) {
if (process == NULL) return false;
if (process->columns <= 0 || process->rows <= 0) return false;
#ifdef _WIN32
COORD size = {(int16_t) process->columns, (int16_t) process->rows};
return pResizePseudoConsole(process->pty, size) == S_OK;
#else
struct winsize size = {process->rows, process->columns, 0, 0};
return ioctl(process->pty, TIOCSWINSZ, &size) == 0;
#endif
}
bool pty_kill(pty_process *process, int sig) {
if (process == NULL) return false;
#ifdef _WIN32
return TerminateProcess(process->handle, 1) != 0;
#else
return uv_kill(-process->pid, sig) == 0;
#endif
}
#ifdef _WIN32
bool conpty_init() {
uv_lib_t kernel;
if (uv_dlopen("kernel32.dll", &kernel)) {
uv_dlclose(&kernel);
return false;
}
static struct {
char *name;
FARPROC *ptr;
} conpty_entry[] = {{"CreatePseudoConsole", (FARPROC *) &pCreatePseudoConsole},
{"ResizePseudoConsole", (FARPROC *) &pResizePseudoConsole},
{"ClosePseudoConsole", (FARPROC *) &pClosePseudoConsole},
{NULL, NULL}};
for (int i = 0; conpty_entry[i].name != NULL && conpty_entry[i].ptr != NULL; i++) {
if (uv_dlsym(&kernel, conpty_entry[i].name, (void **) conpty_entry[i].ptr)) {
uv_dlclose(&kernel);
return false;
}
}
return true;
}
static WCHAR *to_utf16(char *str) {
int len = MultiByteToWideChar(CP_UTF8, 0, str, -1, NULL, 0);
if (len <= 0) return NULL;
WCHAR *wstr = xmalloc((len + 1) * sizeof(WCHAR));
if (len != MultiByteToWideChar(CP_UTF8, 0, str, -1, wstr, len)) {
free(wstr);
return NULL;
}
wstr[len] = L'\0';
return wstr;
}
// convert argv to cmdline for CreateProcessW
static WCHAR *join_args(char **argv) {
char args[256] = {0};
char **ptr = argv;
for (; *ptr; ptr++) {
char *quoted = (char *) quote_arg(*ptr);
size_t arg_len = strlen(args) + 1;
size_t quoted_len = strlen(quoted);
if (arg_len == 1) memset(args, 0, 2);
if (arg_len != 1) strcat(args, " ");
strncat(args, quoted, quoted_len);
if (quoted != *ptr) free(quoted);
}
if (args[255] != '\0') args[255] = '\0'; // truncate
return to_utf16(args);
}
static bool conpty_setup(HPCON *hnd, COORD size, STARTUPINFOEXW *si_ex, char **in_name, char **out_name) {
static int count = 0;
char buf[256];
HPCON pty = INVALID_HANDLE_VALUE;
SECURITY_ATTRIBUTES sa = {0};
HANDLE in_pipe = INVALID_HANDLE_VALUE;
HANDLE out_pipe = INVALID_HANDLE_VALUE;
const DWORD open_mode = PIPE_ACCESS_INBOUND | PIPE_ACCESS_OUTBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE;
const DWORD pipe_mode = PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT;
DWORD pid = GetCurrentProcessId();
bool ret = false;
sa.nLength = sizeof(sa);
snprintf(buf, sizeof(buf), "\\\\.\\pipe\\ttyd-term-in-%d-%d", pid, count);
*in_name = strdup(buf);
snprintf(buf, sizeof(buf), "\\\\.\\pipe\\ttyd-term-out-%d-%d", pid, count);
*out_name = strdup(buf);
in_pipe = CreateNamedPipeA(*in_name, open_mode, pipe_mode, 1, 0, 0, 30000, &sa);
out_pipe = CreateNamedPipeA(*out_name, open_mode, pipe_mode, 1, 0, 0, 30000, &sa);
if (in_pipe == INVALID_HANDLE_VALUE || out_pipe == INVALID_HANDLE_VALUE) {
print_error("CreateNamedPipeA");
goto failed;
}
HRESULT hr = pCreatePseudoConsole(size, in_pipe, out_pipe, 0, &pty);
if (FAILED(hr)) {
print_error("CreatePseudoConsole");
goto failed;
}
si_ex->StartupInfo.cb = sizeof(STARTUPINFOEXW);
si_ex->StartupInfo.dwFlags |= STARTF_USESTDHANDLES;
si_ex->StartupInfo.hStdError = NULL;
si_ex->StartupInfo.hStdInput = NULL;
si_ex->StartupInfo.hStdOutput = NULL;
size_t bytes_required;
InitializeProcThreadAttributeList(NULL, 1, 0, &bytes_required);
si_ex->lpAttributeList = (PPROC_THREAD_ATTRIBUTE_LIST) xmalloc(bytes_required);
if (!InitializeProcThreadAttributeList(si_ex->lpAttributeList, 1, 0, &bytes_required)) {
print_error("InitializeProcThreadAttributeList");
goto failed;
}
if (!UpdateProcThreadAttribute(si_ex->lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, pty, sizeof(HPCON),
NULL, NULL)) {
print_error("UpdateProcThreadAttribute");
goto failed;
}
count++;
*hnd = pty;
ret = true;
goto done;
failed:
ret = false;
free(*in_name);
*in_name = NULL;
free(*out_name);
*out_name = NULL;
done:
if (in_pipe != INVALID_HANDLE_VALUE) CloseHandle(in_pipe);
if (out_pipe != INVALID_HANDLE_VALUE) CloseHandle(out_pipe);
return ret;
}
static void connect_cb(uv_connect_t *req, int status) { free(req); }
static void CALLBACK conpty_exit(void *context, BOOLEAN unused) {
pty_process *process = (pty_process *) context;
uv_async_send(&process->async);
}
static void async_cb(uv_async_t *async) {
pty_process *process = (pty_process *) async->data;
UnregisterWait(process->wait);
DWORD exit_code;
GetExitCodeProcess(process->handle, &exit_code);
process->exit_code = (int) exit_code;
process->exit_signal = 1;
process->exit_cb(process);
uv_close((uv_handle_t *) async, async_free_cb);
process_free(process);
}
int pty_spawn(pty_process *process, pty_read_cb read_cb, pty_exit_cb exit_cb) {
char *in_name = NULL;
char *out_name = NULL;
DWORD flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT;
COORD size = {(int16_t) process->columns, (int16_t) process->rows};
if (!conpty_setup(&process->pty, size, &process->si, &in_name, &out_name)) return 1;
SetConsoleCtrlHandler(NULL, FALSE);
int status = 1;
process->in = xmalloc(sizeof(uv_pipe_t));
process->out = xmalloc(sizeof(uv_pipe_t));
uv_pipe_init(process->loop, process->in, 0);
uv_pipe_init(process->loop, process->out, 0);
uv_connect_t *in_req = xmalloc(sizeof(uv_connect_t));
uv_connect_t *out_req = xmalloc(sizeof(uv_connect_t));
uv_pipe_connect(in_req, process->in, in_name, connect_cb);
uv_pipe_connect(out_req, process->out, out_name, connect_cb);
PROCESS_INFORMATION pi = {0};
WCHAR *cmdline, *cwd;
cmdline = join_args(process->argv);
if (cmdline == NULL) goto cleanup;
if (process->envp != NULL) {
char **p = process->envp;
for (; *p; p++) {
WCHAR *env = to_utf16(*p);
if (env == NULL) goto cleanup;
_wputenv(env);
free(env);
}
}
if (process->cwd != NULL) {
cwd = to_utf16(process->cwd);
if (cwd == NULL) goto cleanup;
}
if (!CreateProcessW(NULL, cmdline, NULL, NULL, FALSE, flags, NULL, cwd, &process->si.StartupInfo, &pi)) {
print_error("CreateProcessW");
DWORD exitCode = 0;
if (GetExitCodeProcess(pi.hProcess, &exitCode)) printf("== exit code: %d\n", exitCode);
goto cleanup;
}
process->pid = pi.dwProcessId;
process->handle = pi.hProcess;
process->paused = true;
process->read_cb = read_cb;
process->exit_cb = exit_cb;
process->async.data = process;
uv_async_init(process->loop, &process->async, async_cb);
if (!RegisterWaitForSingleObject(&process->wait, pi.hProcess, conpty_exit, process, INFINITE, WT_EXECUTEONLYONCE)) {
print_error("RegisterWaitForSingleObject");
goto cleanup;
}
status = 0;
cleanup:
if (in_name != NULL) free(in_name);
if (out_name != NULL) free(out_name);
if (cmdline != NULL) free(cmdline);
if (cwd != NULL) free(cwd);
return status;
}
#else
static bool fd_set_cloexec(const int fd) {
int flags = fcntl(fd, F_GETFD);
if (flags < 0) return false;
return (flags & FD_CLOEXEC) == 0 || fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != -1;
}
static bool fd_duplicate(int fd, uv_pipe_t *pipe) {
int fd_dup = dup(fd);
if (fd_dup < 0) return false;
if (!fd_set_cloexec(fd_dup)) return false;
int status = uv_pipe_open(pipe, fd_dup);
if (status) close(fd_dup);
return status == 0;
}
static void wait_cb(void *arg) {
pty_process *process = (pty_process *) arg;
pid_t pid;
int stat;
do
pid = waitpid(process->pid, &stat, 0);
while (pid != process->pid && errno == EINTR);
if (WIFEXITED(stat)) {
process->exit_code = WEXITSTATUS(stat);
}
if (WIFSIGNALED(stat)) {
int sig = WTERMSIG(stat);
process->exit_code = 128 + sig;
process->exit_signal = sig;
}
uv_async_send(&process->async);
}
static void async_cb(uv_async_t *async) {
pty_process *process = (pty_process *) async->data;
process->exit_cb(process);
uv_close((uv_handle_t *) async, async_free_cb);
process_free(process);
}
int pty_spawn(pty_process *process, pty_read_cb read_cb, pty_exit_cb exit_cb) {
int status = 0;
uv_disable_stdio_inheritance();
int master, pid;
struct winsize size = {process->rows, process->columns, 0, 0};
pid = forkpty(&master, NULL, NULL, &size);
if (pid < 0) {
status = -errno;
return status;
} else if (pid == 0) {
setsid();
if (process->cwd != NULL) chdir(process->cwd);
if (process->envp != NULL) {
char **p = process->envp;
for (; *p; p++) putenv(*p);
}
int ret = execvp(process->argv[0], process->argv);
if (ret < 0) {
perror("execvp failed\n");
_exit(-errno);
}
}
int flags = fcntl(master, F_GETFL);
if (flags == -1) {
status = -errno;
goto error;
}
if (fcntl(master, F_SETFL, flags | O_NONBLOCK) == -1) {
status = -errno;
goto error;
}
if (!fd_set_cloexec(master)) {
status = -errno;
goto error;
}
process->in = xmalloc(sizeof(uv_pipe_t));
process->out = xmalloc(sizeof(uv_pipe_t));
uv_pipe_init(process->loop, process->in, 0);
uv_pipe_init(process->loop, process->out, 0);
if (!fd_duplicate(master, process->in) || !fd_duplicate(master, process->out)) {
status = -errno;
goto error;
}
process->pty = master;
process->pid = pid;
process->paused = true;
process->read_cb = read_cb;
process->exit_cb = exit_cb;
process->async.data = process;
uv_async_init(process->loop, &process->async, async_cb);
uv_thread_create(&process->tid, wait_cb, process);
return 0;
error:
close(master);
uv_kill(pid, SIGKILL);
waitpid(pid, NULL, 0);
return status;
}
#endif

68
src/pty.h Normal file
View File

@@ -0,0 +1,68 @@
#ifndef TTYD_PTY_H
#define TTYD_PTY_H
#include <stdbool.h>
#include <stdint.h>
#include <uv.h>
#ifdef _WIN32
#ifndef HPCON
#define HPCON VOID *
#endif
#ifndef PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
#define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 0x00020016
#endif
bool conpty_init();
#endif
typedef struct {
char *base;
size_t len;
} pty_buf_t;
struct pty_process_;
typedef struct pty_process_ pty_process;
typedef void (*pty_read_cb)(pty_process *, pty_buf_t *, bool);
typedef void (*pty_exit_cb)(pty_process *);
struct pty_process_ {
int pid, exit_code, exit_signal;
uint16_t columns, rows;
#ifdef _WIN32
STARTUPINFOEXW si;
HPCON pty;
HANDLE handle;
HANDLE wait;
#else
pid_t pty;
uv_thread_t tid;
#endif
char **argv;
char **envp;
char *cwd;
uv_loop_t *loop;
uv_async_t async;
uv_pipe_t *in;
uv_pipe_t *out;
bool paused;
pty_read_cb read_cb;
pty_exit_cb exit_cb;
void *ctx;
};
pty_buf_t *pty_buf_init(char *base, size_t len);
void pty_buf_free(pty_buf_t *buf);
pty_process *process_init(void *ctx, uv_loop_t *loop, char *argv[], char *envp[]);
bool process_running(pty_process *process);
void process_free(pty_process *process);
int pty_spawn(pty_process *process, pty_read_cb read_cb, pty_exit_cb exit_cb);
void pty_pause(pty_process *process);
void pty_resume(pty_process *process);
int pty_write(pty_process *process, pty_buf_t *buf);
bool pty_resize(pty_process *process);
bool pty_kill(pty_process *process, int sig);
#endif // TTYD_PTY_H

634
src/server.c Normal file
View File

@@ -0,0 +1,634 @@
#include "server.h"
#include <errno.h>
#include <getopt.h>
#include <json.h>
#include <libwebsockets.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include "utils.h"
#ifndef TTYD_VERSION
#define TTYD_VERSION "unknown"
#endif
volatile bool force_exit = false;
struct lws_context *context;
struct server *server;
struct endpoints endpoints = {"/ws", "/", "/token", ""};
extern int callback_http(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len);
extern int callback_tty(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len);
// websocket protocols
static const struct lws_protocols protocols[] = {{"http-only", callback_http, sizeof(struct pss_http), 0},
{"tty", callback_tty, sizeof(struct pss_tty), 0},
{NULL, NULL, 0, 0}};
#ifndef LWS_WITHOUT_EXTENSIONS
// websocket extensions
static const struct lws_extension extensions[] = {
{"permessage-deflate", lws_extension_callback_pm_deflate, "permessage-deflate"},
{"deflate-frame", lws_extension_callback_pm_deflate, "deflate_frame"},
{NULL, NULL, NULL}};
#endif
#if LWS_LIBRARY_VERSION_NUMBER >= 4000000
static const uint32_t backoff_ms[] = {1000, 2000, 3000, 4000, 5000};
static lws_retry_bo_t retry = {
.retry_ms_table = backoff_ms,
.retry_ms_table_count = LWS_ARRAY_SIZE(backoff_ms),
.conceal_count = LWS_ARRAY_SIZE(backoff_ms),
.secs_since_valid_ping = 5,
.secs_since_valid_hangup = 10,
.jitter_percent = 0,
};
#endif
// command line options
static const struct option options[] = {{"port", required_argument, NULL, 'p'},
{"interface", required_argument, NULL, 'i'},
{"socket-owner", required_argument, NULL, 'U'},
{"credential", required_argument, NULL, 'c'},
{"auth-header", required_argument, NULL, 'H'},
{"uid", required_argument, NULL, 'u'},
{"gid", required_argument, NULL, 'g'},
{"signal", required_argument, NULL, 's'},
{"cwd", required_argument, NULL, 'w'},
{"index", required_argument, NULL, 'I'},
{"base-path", required_argument, NULL, 'b'},
#if LWS_LIBRARY_VERSION_NUMBER >= 4000000
{"ping-interval", required_argument, NULL, 'P'},
#endif
{"srv-buf-size", required_argument, NULL, 'f'},
{"ipv6", no_argument, NULL, '6'},
{"ssl", no_argument, NULL, 'S'},
{"ssl-cert", required_argument, NULL, 'C'},
{"ssl-key", required_argument, NULL, 'K'},
{"ssl-ca", required_argument, NULL, 'A'},
{"url-arg", no_argument, NULL, 'a'},
{"writable", no_argument, NULL, 'W'},
{"terminal-type", required_argument, NULL, 'T'},
{"client-option", required_argument, NULL, 't'},
{"check-origin", no_argument, NULL, 'O'},
{"max-clients", required_argument, NULL, 'm'},
{"once", no_argument, NULL, 'o'},
{"exit-no-conn", no_argument, NULL, 'q'},
{"browser", no_argument, NULL, 'B'},
{"debug", required_argument, NULL, 'd'},
{"version", no_argument, NULL, 'v'},
{"help", no_argument, NULL, 'h'},
{NULL, 0, 0, 0}};
static const char *opt_string = "p:i:U:c:H:u:g:s:w:I:b:P:f:6aSC:K:A:Wt:T:Om:oqBd:vh";
static void print_help() {
// clang-format off
fprintf(stderr, "ttyd is a tool for sharing terminal over the web\n\n"
"USAGE:\n"
" ttyd [options] <command> [<arguments...>]\n\n"
"VERSION:\n"
" %s\n\n"
"OPTIONS:\n"
" -p, --port Port to listen (default: 7681, use `0` for random port)\n"
" -i, --interface Network interface to bind (eg: eth0), or UNIX domain socket path (eg: /var/run/ttyd.sock)\n"
" -U, --socket-owner User owner of the UNIX domain socket file, when enabled (eg: user:group)\n"
" -c, --credential Credential for basic authentication (format: username:password)\n"
" -H, --auth-header HTTP Header name for auth proxy, this will configure ttyd to let a HTTP reverse proxy handle authentication\n"
" -u, --uid User id to run with\n"
" -g, --gid Group id to run with\n"
" -s, --signal Signal to send to the command when exit it (default: 1, SIGHUP)\n"
" -w, --cwd Working directory to be set for the child program\n"
" -a, --url-arg Allow client to send command line arguments in URL (eg: http://localhost:7681?arg=foo&arg=bar)\n"
" -W, --writable Allow clients to write to the TTY (readonly by default)\n"
" -t, --client-option Send option to client (format: key=value), repeat to add more options\n"
" -T, --terminal-type Terminal type to report, default: xterm-256color\n"
" -O, --check-origin Do not allow websocket connection from different origin\n"
" -m, --max-clients Maximum clients to support (default: 0, no limit)\n"
" -o, --once Accept only one client and exit on disconnection\n"
" -q, --exit-no-conn Exit on all clients disconnection\n"
" -B, --browser Open terminal with the default system browser\n"
" -I, --index Custom index.html path\n"
" -b, --base-path Expected base path for requests coming from a reverse proxy (eg: /mounted/here, max length: 128)\n"
#if LWS_LIBRARY_VERSION_NUMBER >= 4000000
" -P, --ping-interval Websocket ping interval(sec) (default: 5)\n"
#endif
" -f, --srv-buf-size Maximum chunk of file (in bytes) that can be sent at once, a larger value may improve throughput (default: 4096)\n"
#ifdef LWS_WITH_IPV6
" -6, --ipv6 Enable IPv6 support\n"
#endif
#if defined(LWS_OPENSSL_SUPPORT) || defined(LWS_WITH_TLS)
" -S, --ssl Enable SSL\n"
" -C, --ssl-cert SSL certificate file path\n"
" -K, --ssl-key SSL key file path\n"
" -A, --ssl-ca SSL CA file path for client certificate verification\n"
#endif
" -d, --debug Set log level (default: 7)\n"
" -v, --version Print the version and exit\n"
" -h, --help Print this text and exit\n\n"
"Visit https://github.com/tsl0922/ttyd to get more information and report bugs.\n",
TTYD_VERSION
);
// clang-format on
}
static void print_config() {
lwsl_notice("tty configuration:\n");
if (server->credential != NULL) lwsl_notice(" credential: %s\n", server->credential);
lwsl_notice(" start command: %s\n", server->command);
lwsl_notice(" close signal: %s (%d)\n", server->sig_name, server->sig_code);
lwsl_notice(" terminal type: %s\n", server->terminal_type);
if (endpoints.parent[0]) {
lwsl_notice("endpoints:\n");
lwsl_notice(" base-path: %s\n", endpoints.parent);
lwsl_notice(" index : %s\n", endpoints.index);
lwsl_notice(" token : %s\n", endpoints.token);
lwsl_notice(" websocket: %s\n", endpoints.ws);
}
if (server->auth_header != NULL) lwsl_notice(" auth header: %s\n", server->auth_header);
if (server->check_origin) lwsl_notice(" check origin: true\n");
if (server->url_arg) lwsl_notice(" allow url arg: true\n");
if (server->max_clients > 0) lwsl_notice(" max clients: %d\n", server->max_clients);
if (server->once) lwsl_notice(" once: true\n");
if (server->exit_no_conn) lwsl_notice(" exit_no_conn: true\n");
if (server->index != NULL) lwsl_notice(" custom index.html: %s\n", server->index);
if (server->cwd != NULL) lwsl_notice(" working directory: %s\n", server->cwd);
if (!server->writable) lwsl_warn("The --writable option is not set, will start in readonly mode\n");
}
static struct server *server_new(int argc, char **argv, int start) {
struct server *ts;
size_t cmd_len = 0;
ts = xmalloc(sizeof(struct server));
memset(ts, 0, sizeof(struct server));
ts->client_count = 0;
ts->sig_code = SIGHUP;
sprintf(ts->terminal_type, "%s", "xterm-256color");
get_sig_name(ts->sig_code, ts->sig_name, sizeof(ts->sig_name));
if (start == argc) return ts;
int cmd_argc = argc - start;
char **cmd_argv = &argv[start];
ts->argv = xmalloc(sizeof(char *) * (cmd_argc + 1));
for (int i = 0; i < cmd_argc; i++) {
ts->argv[i] = strdup(cmd_argv[i]);
cmd_len += strlen(ts->argv[i]);
if (i != cmd_argc - 1) {
cmd_len++; // for space
}
}
ts->argv[cmd_argc] = NULL;
ts->argc = cmd_argc;
ts->command = xmalloc(cmd_len + 1);
char *ptr = ts->command;
for (int i = 0; i < cmd_argc; i++) {
size_t len = strlen(ts->argv[i]);
ptr = memcpy(ptr, ts->argv[i], len + 1) + len;
if (i != cmd_argc - 1) {
*ptr++ = ' ';
}
}
*ptr = '\0'; // null terminator
ts->loop = xmalloc(sizeof *ts->loop);
uv_loop_init(ts->loop);
return ts;
}
static void server_free(struct server *ts) {
if (ts == NULL) return;
if (ts->credential != NULL) free(ts->credential);
if (ts->auth_header != NULL) free(ts->auth_header);
if (ts->index != NULL) free(ts->index);
if (ts->cwd != NULL) free(ts->cwd);
free(ts->command);
free(ts->prefs_json);
char **p = ts->argv;
for (; *p; p++) free(*p);
free(ts->argv);
if (strlen(ts->socket_path) > 0) {
struct stat st;
if (!stat(ts->socket_path, &st)) {
unlink(ts->socket_path);
}
}
uv_loop_close(ts->loop);
free(ts->loop);
free(ts);
}
static void signal_cb(uv_signal_t *watcher, int signum) {
char sig_name[20];
switch (watcher->signum) {
case SIGINT:
case SIGTERM:
get_sig_name(watcher->signum, sig_name, sizeof(sig_name));
lwsl_notice("received signal: %s (%d), exiting...\n", sig_name, watcher->signum);
break;
default:
signal(SIGABRT, SIG_DFL);
abort();
}
if (force_exit) exit(EXIT_FAILURE);
force_exit = true;
lws_cancel_service(context);
uv_stop(server->loop);
lwsl_notice("send ^C to force exit.\n");
}
static int parse_int(char *name, char *str) {
char *endptr;
errno = 0;
long val = strtol(str, &endptr, 0);
if (errno != 0 || endptr == str) {
fprintf(stderr, "ttyd: invalid value for %s: %s\n", name, str);
exit(EXIT_FAILURE);
}
return (int)val;
}
static int calc_command_start(int argc, char **argv) {
// make a copy of argc and argv
int argc_copy = argc;
char **argv_copy = xmalloc(sizeof(char *) * argc);
for (int i = 0; i < argc; i++) {
argv_copy[i] = strdup(argv[i]);
}
// do not print error message for invalid option
opterr = 0;
while (getopt_long(argc_copy, argv_copy, opt_string, options, NULL) != -1)
;
int start = argc;
if (optind < argc) {
char *command = argv_copy[optind];
for (int i = 0; i < argc; i++) {
if (strcmp(argv[i], command) == 0) {
start = i;
break;
}
}
}
// free argv copy
for (int i = 0; i < argc; i++) {
free(argv_copy[i]);
}
free(argv_copy);
// reset for next use
opterr = 1;
optind = 0;
return start;
}
int main(int argc, char **argv) {
if (argc == 1) {
print_help();
return 0;
}
#ifdef _WIN32
if (!conpty_init()) {
fprintf(stderr, "ERROR: ConPTY init failed! Make sure you are on Windows 10 1809 or later.");
return 1;
}
#endif
int start = calc_command_start(argc, argv);
server = server_new(argc, argv, start);
struct lws_context_creation_info info;
memset(&info, 0, sizeof(info));
info.port = 7681;
info.iface = NULL;
info.protocols = protocols;
info.gid = -1;
info.uid = -1;
info.max_http_header_pool = 16;
info.options = LWS_SERVER_OPTION_LIBUV | LWS_SERVER_OPTION_VALIDATE_UTF8 | LWS_SERVER_OPTION_DISABLE_IPV6;
#ifndef LWS_WITHOUT_EXTENSIONS
info.extensions = extensions;
#endif
info.max_http_header_data = 65535;
int debug_level = LLL_ERR | LLL_WARN | LLL_NOTICE;
char iface[128] = "";
char socket_owner[128] = "";
bool browser = false;
bool ssl = false;
char cert_path[1024] = "";
char key_path[1024] = "";
char ca_path[1024] = "";
struct json_object *client_prefs = json_object_new_object();
#ifdef _WIN32
json_object_object_add(client_prefs, "isWindows", json_object_new_boolean(true));
#endif
// parse command line options
int c;
while ((c = getopt_long(start, argv, opt_string, options, NULL)) != -1) {
switch (c) {
case 'h':
print_help();
return 0;
case 'v':
printf("ttyd version %s\n", TTYD_VERSION);
return 0;
case 'd':
debug_level = parse_int("debug", optarg);
break;
case 'a':
server->url_arg = true;
break;
case 'W':
server->writable = true;
break;
case 'O':
server->check_origin = true;
break;
case 'm':
server->max_clients = parse_int("max-clients", optarg);
break;
case 'o':
server->once = true;
break;
case 'q':
server->exit_no_conn = true;
break;
case 'B':
browser = true;
break;
case 'p':
info.port = parse_int("port", optarg);
if (info.port < 0) {
fprintf(stderr, "ttyd: invalid port: %s\n", optarg);
return -1;
}
break;
case 'i':
strncpy(iface, optarg, sizeof(iface) - 1);
iface[sizeof(iface) - 1] = '\0';
break;
case 'U':
strncpy(socket_owner, optarg, sizeof(socket_owner) - 1);
socket_owner[sizeof(socket_owner) - 1] = '\0';
break;
case 'c':
if (strchr(optarg, ':') == NULL) {
fprintf(stderr, "ttyd: invalid credential, format: username:password\n");
return -1;
}
char b64_text[256];
lws_b64_encode_string(optarg, strlen(optarg), b64_text, sizeof(b64_text));
server->credential = strdup(b64_text);
break;
case 'H':
server->auth_header = strdup(optarg);
break;
case 'u':
info.uid = parse_int("uid", optarg);
break;
case 'g':
info.gid = parse_int("gid", optarg);
break;
case 's': {
int sig = get_sig(optarg);
if (sig > 0) {
server->sig_code = sig;
get_sig_name(sig, server->sig_name, sizeof(server->sig_name));
} else {
fprintf(stderr, "ttyd: invalid signal: %s\n", optarg);
return -1;
}
} break;
case 'w':
server->cwd = strdup(optarg);
break;
case 'I':
if (!strncmp(optarg, "~/", 2)) {
const char *home = getenv("HOME");
server->index = malloc(strlen(home) + strlen(optarg) - 1);
sprintf(server->index, "%s%s", home, optarg + 1);
} else {
server->index = strdup(optarg);
}
struct stat st;
if (stat(server->index, &st) == -1) {
fprintf(stderr, "Can not stat index.html: %s, error: %s\n", server->index, strerror(errno));
return -1;
}
if (S_ISDIR(st.st_mode)) {
fprintf(stderr, "Invalid index.html path: %s, is it a dir?\n", server->index);
return -1;
}
break;
case 'b': {
char path[128];
strncpy(path, optarg, 128);
size_t len = strlen(path);
while (len && path[len - 1] == '/') path[--len] = 0; // trim trailing /
if (!len) break;
#define sc(f) \
strncpy(path + len, endpoints.f, 128 - len); \
endpoints.f = strdup(path);
sc(ws) sc(index) sc(token) sc(parent)
#undef sc
} break;
#if LWS_LIBRARY_VERSION_NUMBER >= 4000000
case 'P': {
int interval = parse_int("ping-interval", optarg);
if (interval < 0) {
fprintf(stderr, "ttyd: invalid ping interval: %s\n", optarg);
return -1;
}
retry.secs_since_valid_ping = interval;
retry.secs_since_valid_hangup = interval + 7;
} break;
#endif
case 'f': {
int serv_buf_size = parse_int("srv-buf-size", optarg);
if (serv_buf_size < 0) {
fprintf(stderr, "ttyd: invalid srv-buf-size: %s\n", optarg);
return -1;
}
info.pt_serv_buf_size = serv_buf_size;
} break;
case '6':
info.options &= ~(LWS_SERVER_OPTION_DISABLE_IPV6);
break;
#if defined(LWS_OPENSSL_SUPPORT) || defined(LWS_WITH_TLS)
case 'S':
ssl = true;
break;
case 'C':
strncpy(cert_path, optarg, sizeof(cert_path) - 1);
cert_path[sizeof(cert_path) - 1] = '\0';
break;
case 'K':
strncpy(key_path, optarg, sizeof(key_path) - 1);
key_path[sizeof(key_path) - 1] = '\0';
break;
case 'A':
strncpy(ca_path, optarg, sizeof(ca_path) - 1);
ca_path[sizeof(ca_path) - 1] = '\0';
break;
#endif
case 'T':
strncpy(server->terminal_type, optarg, sizeof(server->terminal_type) - 1);
server->terminal_type[sizeof(server->terminal_type) - 1] = '\0';
break;
case '?':
break;
case 't':
optind--;
for (; optind < start && *argv[optind] != '-'; optind++) {
char *option = optarg;
char *key = strsep(&option, "=");
if (key == NULL) {
fprintf(stderr, "ttyd: invalid client option: %s, format: key=value\n", optarg);
return -1;
}
char *value = strsep(&option, "=");
if (value == NULL) {
fprintf(stderr, "ttyd: invalid client option: %s, format: key=value\n", optarg);
return -1;
}
struct json_object *obj = json_tokener_parse(value);
json_object_object_add(client_prefs, key, obj != NULL ? obj : json_object_new_string(value));
}
break;
default:
print_help();
return -1;
}
}
server->prefs_json = strdup(json_object_to_json_string(client_prefs));
json_object_put(client_prefs);
if (server->command == NULL || strlen(server->command) == 0) {
fprintf(stderr, "ttyd: missing start command\n");
return -1;
}
lws_set_log_level(debug_level, NULL);
char server_hdr[128] = "";
sprintf(server_hdr, "ttyd/%s (libwebsockets/%s)", TTYD_VERSION, LWS_LIBRARY_VERSION);
info.server_string = server_hdr;
#if LWS_LIBRARY_VERSION_NUMBER < 4000000
info.ws_ping_pong_interval = 5;
#else
info.retry_and_idle_policy = &retry;
#endif
if (strlen(iface) > 0) {
info.iface = iface;
if (endswith(info.iface, ".sock") || endswith(info.iface, ".socket")) {
#if defined(LWS_USE_UNIX_SOCK) || defined(LWS_WITH_UNIX_SOCK)
info.options |= LWS_SERVER_OPTION_UNIX_SOCK;
info.port = 0; // warmcat/libwebsockets#1985
strncpy(server->socket_path, info.iface, sizeof(server->socket_path) - 1);
if (strlen(socket_owner) > 0) {
info.unix_socket_perms = socket_owner;
}
#else
fprintf(stderr, "libwebsockets is not compiled with UNIX domain socket support");
return -1;
#endif
}
}
#if defined(LWS_OPENSSL_SUPPORT) || defined(LWS_WITH_TLS)
if (ssl) {
info.ssl_cert_filepath = cert_path;
info.ssl_private_key_filepath = key_path;
#ifndef LWS_WITH_MBEDTLS
info.ssl_options_set = SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1;
#endif
if (strlen(ca_path) > 0) {
info.ssl_ca_filepath = ca_path;
info.options |= LWS_SERVER_OPTION_REQUIRE_VALID_OPENSSL_CLIENT_CERT;
}
info.options |= LWS_SERVER_OPTION_ALLOW_NON_SSL_ON_SSL_PORT | LWS_SERVER_OPTION_REDIRECT_HTTP_TO_HTTPS;
}
#endif
lwsl_notice("ttyd %s (libwebsockets %s)\n", TTYD_VERSION, LWS_LIBRARY_VERSION);
print_config();
// lws custom header requires lower case name, and terminating :
if (server->auth_header != NULL) {
size_t auth_header_len = strlen(server->auth_header);
server->auth_header = xrealloc(server->auth_header, auth_header_len + 2);
strcat(server->auth_header + auth_header_len, ":");
lowercase(server->auth_header);
}
void *foreign_loops[1];
foreign_loops[0] = server->loop;
info.foreign_loops = foreign_loops;
info.options |= LWS_SERVER_OPTION_EXPLICIT_VHOSTS;
context = lws_create_context(&info);
if (context == NULL) {
lwsl_err("libwebsockets context creation failed\n");
return 1;
}
struct lws_vhost *vhost = lws_create_vhost(context, &info);
if (vhost == NULL) {
lwsl_err("libwebsockets vhost creation failed\n");
return 1;
}
int port = lws_get_vhost_listen_port(vhost);
lwsl_notice(" Listening on port: %d\n", port);
if (browser) {
char url[30];
sprintf(url, "%s://localhost:%d", ssl ? "https" : "http", port);
open_uri(url);
}
#define sig_count 2
int sig_nums[] = {SIGINT, SIGTERM};
uv_signal_t signals[sig_count];
for (int i = 0; i < sig_count; i++) {
uv_signal_init(server->loop, &signals[i]);
uv_signal_start(&signals[i], signal_cb, sig_nums[i]);
}
lws_service(context, 0);
for (int i = 0; i < sig_count; i++) {
uv_signal_stop(&signals[i]);
}
#undef sig_count
lws_context_destroy(context);
// cleanup
server_free(server);
return 0;
}

86
src/server.h Normal file
View File

@@ -0,0 +1,86 @@
#include <libwebsockets.h>
#include <stdbool.h>
#include <uv.h>
#include "pty.h"
// client message
#define INPUT '0'
#define RESIZE_TERMINAL '1'
#define PAUSE '2'
#define RESUME '3'
#define JSON_DATA '{'
// server message
#define OUTPUT '0'
#define SET_WINDOW_TITLE '1'
#define SET_PREFERENCES '2'
// url paths
struct endpoints {
char *ws;
char *index;
char *token;
char *parent;
};
extern volatile bool force_exit;
extern struct lws_context *context;
extern struct server *server;
extern struct endpoints endpoints;
struct pss_http {
char path[128];
char *buffer;
char *ptr;
size_t len;
};
struct pss_tty {
bool initialized;
int initial_cmd_index;
bool authenticated;
char user[30];
char address[50];
char path[128];
char **args;
int argc;
struct lws *wsi;
char *buffer;
size_t len;
pty_process *process;
pty_buf_t *pty_buf;
int lws_close_status;
};
typedef struct {
struct pss_tty *pss;
bool ws_closed;
} pty_ctx_t;
struct server {
int client_count; // client count
char *prefs_json; // client preferences
char *credential; // encoded basic auth credential
char *auth_header; // header name used for auth proxy
char *index; // custom index.html
char *command; // full command line
char **argv; // command with arguments
int argc; // command + arguments count
char *cwd; // working directory
int sig_code; // close signal
char sig_name[20]; // human readable signal string
bool url_arg; // allow client to send cli arguments in URL
bool writable; // whether clients to write to the TTY
bool check_origin; // whether allow websocket connection from different origin
int max_clients; // maximum clients to support
bool once; // whether accept only one client and exit on disconnection
bool exit_no_conn; // whether exit on all clients disconnection
char socket_path[255]; // UNIX domain socket path
char terminal_type[30]; // terminal type to report
uv_loop_t *loop; // the libuv event loop
};

163
src/utils.c Normal file
View File

@@ -0,0 +1,163 @@
#include <ctype.h>
#include <fcntl.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__linux__) && !defined(__ANDROID__)
const char *sys_signame[NSIG] = {
"zero", "HUP", "INT", "QUIT", "ILL", "TRAP", "ABRT", "UNUSED", "FPE", "KILL", "USR1",
"SEGV", "USR2", "PIPE", "ALRM", "TERM", "STKFLT", "CHLD", "CONT", "STOP", "TSTP", "TTIN",
"TTOU", "URG", "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "IO", "PWR", "SYS", NULL};
#endif
#if defined(_WIN32) || defined(__CYGWIN__)
#include <windows.h>
#undef NSIG
#define NSIG 33
const char *sys_signame[NSIG] = {
"zero", "HUP", "INT", "QUIT", "ILL", "TRAP", "IOT", "EMT", "FPE", "KILL", "BUS",
"SEGV", "SYS", "PIPE", "ALRM", "TERM", "URG", "STOP", "TSTP", "CONT", "CHLD", "TTIN",
"TTOU", "IO", "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "PWR", "USR1", "USR2", NULL};
#endif
void *xmalloc(size_t size) {
if (size == 0) return NULL;
void *p = malloc(size);
if (!p) abort();
return p;
}
void *xrealloc(void *p, size_t size) {
if ((size == 0) && (p == NULL)) return NULL;
p = realloc(p, size);
if (!p) abort();
return p;
}
char *uppercase(char *s) {
while(*s) {
*s = (char)toupper((int)*s);
s++;
}
return s;
}
char *lowercase(char *s) {
while(*s) {
*s = (char)tolower((int)*s);
s++;
}
return s;
}
bool endswith(const char *str, const char *suffix) {
size_t str_len = strlen(str);
size_t suffix_len = strlen(suffix);
return str_len > suffix_len && !strcmp(str + (str_len - suffix_len), suffix);
}
int get_sig_name(int sig, char *buf, size_t len) {
int n = snprintf(buf, len, "SIG%s", sig < NSIG ? sys_signame[sig] : "unknown");
uppercase(buf);
return n;
}
int get_sig(const char *sig_name) {
for (int sig = 1; sig < NSIG; sig++) {
const char *name = sys_signame[sig];
if (name != NULL && (strcasecmp(name, sig_name) == 0 || strcasecmp(name, sig_name + 3) == 0))
return sig;
}
return atoi(sig_name);
}
int open_uri(char *uri) {
#ifdef __APPLE__
char command[256];
sprintf(command, "open %s > /dev/null 2>&1", uri);
return system(command);
#elif defined(_WIN32) || defined(__CYGWIN__)
return ShellExecute(0, 0, uri, 0, 0, SW_SHOW) > (HINSTANCE)32 ? 0 : 1;
#else
// check if X server is running
if (system("xset -q > /dev/null 2>&1")) return 1;
char command[256];
sprintf(command, "xdg-open %s > /dev/null 2>&1", uri);
return system(command);
#endif
}
#ifdef _WIN32
char *strsep(char **sp, char *sep) {
char *p, *s;
if (sp == NULL || *sp == NULL || **sp == '\0') return (NULL);
s = *sp;
p = s + strcspn(s, sep);
if (*p != '\0') *p++ = '\0';
*sp = p;
return s;
}
const char *quote_arg(const char *arg) {
int len = 0, n = 0;
int force_quotes = 0;
char *q, *d;
const char *p = arg;
if (!*p) force_quotes = 1;
while (*p) {
if (isspace(*p) || *p == '*' || *p == '?' || *p == '{' || *p == '\'')
force_quotes = 1;
else if (*p == '"')
n++;
else if (*p == '\\') {
int count = 0;
while (*p == '\\') {
count++;
p++;
len++;
}
if (*p == '"' || !*p) n += count * 2 + 1;
continue;
}
len++;
p++;
}
if (!force_quotes && n == 0) return arg;
d = q = xmalloc(len + n + 3);
*d++ = '"';
while (*arg) {
if (*arg == '"')
*d++ = '\\';
else if (*arg == '\\') {
int count = 0;
while (*arg == '\\') {
count++;
*d++ = *arg++;
}
if (*arg == '"' || !*arg) {
while (count-- > 0) *d++ = '\\';
if (!*arg) break;
*d++ = '\\';
}
}
*d++ = *arg++;
}
*d++ = '"';
*d++ = '\0';
return q;
}
void print_error(char *func) {
LPVOID buffer;
DWORD dw = GetLastError();
FormatMessage(
FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, dw, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPTSTR)&buffer, 0, NULL);
wprintf(L"== %s failed with error %d: %s", func, dw, buffer);
LocalFree(buffer);
}
#endif

39
src/utils.h Normal file
View File

@@ -0,0 +1,39 @@
#ifndef TTYD_UTIL_H
#define TTYD_UTIL_H
#define container_of(ptr, type, member) \
({ \
const typeof(((type *)0)->member) *__mptr = (ptr); \
(type *)((char *)__mptr - offsetof(type, member)); \
})
// malloc with NULL check
void *xmalloc(size_t size);
// realloc with NULL check
void *xrealloc(void *p, size_t size);
// Convert a string to upper case
char *uppercase(char *s);
// Convert a string to lower case
char *lowercase(char *s);
// Check whether str ends with suffix
bool endswith(const char *str, const char *suffix);
// Get human readable signal string
int get_sig_name(int sig, char *buf, size_t len);
// Get signal code from string like SIGHUP
int get_sig(const char *sig_name);
// Open uri with the default application of system
int open_uri(char *uri);
#ifdef _WIN32
char *strsep(char **sp, char *sep);
const char *quote_arg(const char *arg);
void print_error(char *func);
#endif
#endif // TTYD_UTIL_H