Repository: jingweno/upterm Branch: master Commit: dfb195a69709 Files: 172 Total size: 666.3 KB Directory structure: gitextract_ivjo9rd1/ ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ ├── build-and-release.yaml │ ├── build.yaml │ ├── codeql-analysis.yml │ ├── e2e.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yml ├── CONTRIBUTING.md ├── Dockerfile.uptermd ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── app.json ├── charts/ │ └── uptermd/ │ ├── .helmignore │ ├── Chart.yaml │ ├── templates/ │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── configmap.yaml │ │ ├── deployment.yaml │ │ ├── hpa.yaml │ │ ├── ingress.yaml │ │ ├── issuer.yaml │ │ ├── secret.yaml │ │ ├── service.yaml │ │ ├── serviceaccount.yaml │ │ └── tests/ │ │ └── test-connection.yaml │ └── values.yaml ├── cmd/ │ ├── gendoc/ │ │ └── main.go │ ├── upterm/ │ │ ├── command/ │ │ │ ├── config.go │ │ │ ├── host.go │ │ │ ├── host_test.go │ │ │ ├── host_unix.go │ │ │ ├── host_windows.go │ │ │ ├── internal/ │ │ │ │ └── tui/ │ │ │ │ ├── host_session.go │ │ │ │ ├── session_detail.go │ │ │ │ ├── session_detail_test.go │ │ │ │ ├── session_list.go │ │ │ │ └── styles.go │ │ │ ├── privacy.go │ │ │ ├── proxy.go │ │ │ ├── root.go │ │ │ ├── session.go │ │ │ ├── sftp_permission.go │ │ │ ├── upgrade.go │ │ │ └── version.go │ │ └── main.go │ ├── uptermd/ │ │ ├── command/ │ │ │ ├── root.go │ │ │ └── version.go │ │ └── main.go │ └── uptermd-fly/ │ └── main.go ├── docs/ │ ├── upterm.md │ ├── upterm_config.md │ ├── upterm_config_edit.md │ ├── upterm_config_path.md │ ├── upterm_config_view.md │ ├── upterm_host.md │ ├── upterm_proxy.md │ ├── upterm_session.md │ ├── upterm_session_current.md │ ├── upterm_session_info.md │ ├── upterm_session_list.md │ ├── upterm_upgrade.md │ └── upterm_version.md ├── etc/ │ ├── completion/ │ │ ├── upterm.bash_completion.sh │ │ └── upterm.zsh_completion │ └── man/ │ └── man1/ │ ├── upterm-config-edit.1 │ ├── upterm-config-path.1 │ ├── upterm-config-view.1 │ ├── upterm-config.1 │ ├── upterm-host.1 │ ├── upterm-proxy.1 │ ├── upterm-session-current.1 │ ├── upterm-session-info.1 │ ├── upterm-session-list.1 │ ├── upterm-session.1 │ ├── upterm-upgrade.1 │ ├── upterm-version.1 │ └── upterm.1 ├── fly.example.toml ├── fly.toml ├── ftests/ │ ├── client_test.go │ ├── ftests_test.go │ ├── host_test.go │ └── sftp_test.go ├── go.mod ├── go.sum ├── host/ │ ├── adminclient.go │ ├── api/ │ │ ├── api.pb.go │ │ ├── api.proto │ │ └── api_grpc.pb.go │ ├── authorizedkeys.go │ ├── host.go │ ├── host_test.go │ ├── host_unix.go │ ├── host_windows.go │ ├── internal/ │ │ ├── adminserver.go │ │ ├── client.go │ │ ├── command.go │ │ ├── command_test.go │ │ ├── command_unix.go │ │ ├── command_unix_test.go │ │ ├── command_windows.go │ │ ├── command_windows_test.go │ │ ├── event.go │ │ ├── pty.go │ │ ├── pty_unix.go │ │ ├── pty_windows.go │ │ ├── reversetunnel.go │ │ ├── server.go │ │ ├── sftp.go │ │ └── sftp_test.go │ ├── sftp/ │ │ └── permission.go │ ├── signer.go │ └── signer_test.go ├── icon/ │ └── upterm.go ├── internal/ │ ├── context/ │ │ └── logging.go │ ├── e2e/ │ │ ├── e2e_test.go │ │ └── sftp_test.go │ ├── logging/ │ │ └── logging.go │ ├── testhelpers/ │ │ └── consul.go │ └── version/ │ ├── version.go │ └── version_test.go ├── io/ │ ├── query_filter.go │ ├── query_filter_test.go │ ├── reader.go │ ├── reader_test.go │ ├── writer.go │ └── writer_test.go ├── memlistener/ │ ├── memlistener.go │ └── memlistener_test.go ├── routing/ │ ├── encoding.go │ ├── encoding_test.go │ └── modes.go ├── script/ │ ├── changelog │ ├── do-install │ ├── heroku-install │ ├── publish-release │ ├── publish-website │ └── tag-release ├── server/ │ ├── cert.go │ ├── metrics.go │ ├── network.go │ ├── server.go │ ├── server.pb.go │ ├── server.proto │ ├── session.go │ ├── session_test.go │ ├── sshd.go │ ├── sshd_test.go │ ├── sshhandler.go │ ├── sshhandler_test.go │ ├── sshproxy.go │ ├── sshproxy_test.go │ ├── sshrouting.go │ ├── wsproxy.go │ └── wsproxy_test.go ├── systemd/ │ └── uptermd.service ├── terraform/ │ ├── digitalocean/ │ │ ├── charts.tf │ │ ├── do.tf │ │ ├── output.tf │ │ ├── providers.tf │ │ └── variables.tf │ └── heroku/ │ ├── main.tf │ └── providers.tf ├── upterm/ │ └── const.go ├── utils/ │ ├── testing.go │ ├── utils.go │ └── utils_test.go └── ws/ └── client.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: owenthereal open_collective: upterm ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/workflows/build-and-release.yaml ================================================ name: Build and Release on: workflow_call: inputs: snapshot: description: 'Build snapshot (no publishing)' required: false default: true type: boolean docker_repo: description: 'Docker repository' required: false default: 'ghcr.io/owenthereal/upterm/uptermd' type: string jobs: goreleaser: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Set up Docker QEMU uses: docker/setup-qemu-action@v4 with: platforms: 'amd64,arm64,ppc64le,s390x' - name: Login to ghcr.io if: ${{ !inputs.snapshot }} uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GH_TOKEN }} - name: Run GoReleaser (Snapshot) if: ${{ inputs.snapshot }} uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: '~> v2' args: release --clean --snapshot --skip=publish env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} DOCKER_REPO: ${{ inputs.docker_repo }} - name: Run GoReleaser (Release) if: ${{ !inputs.snapshot }} uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: '~> v2' args: release --clean env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} DOCKER_REPO: ${{ inputs.docker_repo }} ================================================ FILE: .github/workflows/build.yaml ================================================ name: Build on: push: branches: - master pull_request: permissions: contents: write packages: write jobs: build: name: Compile runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - name: Compile run: make install test-macos: name: Test (macOS) runs-on: macos-latest steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - name: Test run: make test env: BASH_SILENCE_DEPRECATION_WARNING: 1 MUTE_FLAKY_TESTS: 1 test-ubuntu: name: Test (Ubuntu + Consul) runs-on: ubuntu-latest services: consul: image: consul:1.15 ports: - 8500:8500 options: >- --health-cmd "consul members" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - name: Test run: make test env: BASH_SILENCE_DEPRECATION_WARNING: 1 MUTE_FLAKY_TESTS: 1 test-windows: name: Test (Windows) runs-on: windows-latest steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - name: Test run: make test env: MUTE_FLAKY_TESTS: 1 vet: name: Vet runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - name: Vet run: make vet test-e2e: name: E2E Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - name: Install tmux run: sudo apt-get update && sudo apt-get install -y tmux - name: Build upterm run: make install - name: Generate SSH host keys run: | mkdir -p /tmp/uptermd ssh-keygen -t ed25519 -f /tmp/uptermd/id_ed25519 -N "" - name: Build and start uptermd run: | go build -o /tmp/uptermd/uptermd ./cmd/uptermd /tmp/uptermd/uptermd --ssh-addr 127.0.0.1:2222 --private-key /tmp/uptermd/id_ed25519 > /tmp/uptermd/uptermd.log 2>&1 & echo "Waiting for uptermd to start..." for i in $(seq 1 30); do if nc -z 127.0.0.1 2222 2>/dev/null; then echo "uptermd is ready" break fi if [ $i -eq 30 ]; then echo "uptermd failed to start" cat /tmp/uptermd/uptermd.log exit 1 fi sleep 1 done - name: Run E2E Tests run: make test-e2e env: UPTERM_E2E_SERVER: ssh://127.0.0.1:2222 - name: Cleanup uptermd if: always() run: pkill uptermd || true build-and-release: name: Build and Release (Snapshot) uses: ./.github/workflows/build-and-release.yaml with: snapshot: true docker_repo: ghcr.io/owenthereal/upterm/uptermd secrets: inherit ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ master ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '26 15 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://git.io/codeql-language-support steps: - name: Checkout repository uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 ================================================ FILE: .github/workflows/e2e.yaml ================================================ name: E2E Tests on: workflow_dispatch: inputs: uptermd_url: description: 'Uptermd server URL (e.g., ssh://uptermd.upterm.dev:22)' required: true default: 'ssh://uptermd.upterm.dev:22' jobs: e2e: name: E2E Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - name: Install tmux run: sudo apt-get update && sudo apt-get install -y tmux - name: Install upterm (latest release) run: | mkdir -p /tmp/upterm-release curl -sL https://github.com/owenthereal/upterm/releases/latest/download/upterm_linux_amd64.tar.gz | tar xz -C /tmp/upterm-release sudo mv /tmp/upterm-release/upterm /usr/local/bin/ - name: Run E2E Tests run: make test-e2e env: UPTERM_E2E_SERVER: ${{ inputs.uptermd_url }} ================================================ FILE: .github/workflows/release.yaml ================================================ name: Release on: push: tags: - 'v*' permissions: contents: write packages: write jobs: build-and-release: name: Release binaries and Docker images uses: ./.github/workflows/build-and-release.yaml with: snapshot: false docker_repo: ghcr.io/owenthereal/upterm/uptermd secrets: inherit deploy: name: Deploy app runs-on: ubuntu-latest needs: [build-and-release] steps: - uses: actions/checkout@v6 - uses: superfly/flyctl-actions/setup-flyctl@master - name: Get version from tag id: version run: | VERSION=${GITHUB_REF#refs/tags/v} echo "version=$VERSION" >> $GITHUB_OUTPUT echo "git_commit=$GITHUB_SHA" >> $GITHUB_OUTPUT echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT - name: Deploy to Fly.io run: | flyctl deploy --remote-only \ --build-arg VERSION=${{ steps.version.outputs.version }} \ --build-arg GIT_COMMIT=${{ steps.version.outputs.git_commit }} \ --build-arg BUILD_DATE=${{ steps.version.outputs.build_date }} env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ================================================ FILE: .gitignore ================================================ build c.out release .terraform *.tfstate *.tfstate.backup dist bin CLAUDE.md .claude/ *.exe *.exe~ ================================================ FILE: .goreleaser.yml ================================================ version: 2 builds: - id: upterm env: - CGO_ENABLED=0 goos: - linux - darwin - windows goarch: - "386" - "amd64" - "arm" - "arm64" - "ppc64le" - "s390x" main: ./cmd/upterm ldflags: - -s -w -X github.com/owenthereal/upterm/internal/version.Version={{.Version}} -X github.com/owenthereal/upterm/internal/version.GitCommit={{.Commit}} -X github.com/owenthereal/upterm/internal/version.Date={{.CommitDate}} - id: uptermd env: - CGO_ENABLED=0 goos: - linux goarch: - "amd64" - "arm64" - "ppc64le" - "s390x" main: ./cmd/uptermd binary: uptermd ldflags: - -s -w -X github.com/owenthereal/upterm/internal/version.Version={{.Version}} -X github.com/owenthereal/upterm/internal/version.GitCommit={{.Commit}} -X github.com/owenthereal/upterm/internal/version.Date={{.CommitDate}} archives: - id: upterm name_template: '{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' wrap_in_directory: false ids: - upterm files: - LICENSE* - README* - etc/* - docs/* - id: uptermd name_template: '{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' wrap_in_directory: false ids: - uptermd files: - LICENSE* - README* homebrew_casks: - repository: owner: owenthereal name: homebrew-upterm commit_author: name: Owen Ou email: o@owenou.com homepage: https://upterm.dev description: Instant Terminal Sharing directory: Casks ids: - upterm license: "Apache 2.0" manpages: - "etc/man/man1/upterm.1" - "etc/man/man1/upterm-host.1" - "etc/man/man1/upterm-proxy.1" - "etc/man/man1/upterm-session.1" - "etc/man/man1/upterm-session-current.1" - "etc/man/man1/upterm-session-info.1" - "etc/man/man1/upterm-session-list.1" - "etc/man/man1/upterm-upgrade.1" - "etc/man/man1/upterm-version.1" completions: bash: "etc/completion/upterm.bash_completion.sh" zsh: "etc/completion/upterm.zsh_completion" hooks: post: install: | if OS.mac? system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/upterm"] end scoops: - repository: owner: owenthereal name: scoop-upterm commit_author: name: Owen Ou email: o@owenou.com homepage: https://upterm.dev description: Instant Terminal Sharing license: Apache-2.0 ids: - upterm dockers_v2: - dockerfile: Dockerfile.uptermd ids: - uptermd images: - "{{ .Env.DOCKER_REPO }}" tags: - "{{ .Tag }}" - latest platforms: - linux/amd64 - linux/arm64 - linux/ppc64le - linux/s390x flags: - "--target=pre-built-binary" labels: "org.opencontainers.image.title": "{{ .ProjectName }}" "org.opencontainers.image.description": "Upterm server daemon" "org.opencontainers.image.url": "https://github.com/owenthereal/upterm" "org.opencontainers.image.source": "https://github.com/owenthereal/upterm" "org.opencontainers.image.version": "{{ .Version }}" "org.opencontainers.image.created": '{{ time "2006-01-02T15:04:05Z07:00" }}' "org.opencontainers.image.revision": "{{ .FullCommit }}" "org.opencontainers.image.licenses": "Apache-2.0" extra_files: - go.mod - go.sum checksum: name_template: "checksums.txt" snapshot: version_template: "{{ incpatch .Version }}-snapshot" release: prerelease: auto name_template: "Upterm {{.Version}}" mode: append changelog: sort: asc use: github filters: exclude: - "^docs:" - "^script:" - "^go.mod:" - "^.github:" - Merge branch nfpms: #build:linux - license: Apache-2.0 maintainer: Owen Ou ids: - upterm homepage: https://github.com/owenthereal/upterm bindir: /usr/bin description: Instant Terminal Sharing file_name_template: '{{ .PackageName }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' formats: - deb - rpm contents: - src: "./etc/man/man1/upterm*.1" dst: "/usr/share/man/man1" - src: "./etc/completion/upterm.bash_completion.sh" dst: "/usr/share/bash-completion/completions/upterm" - src: "./etc/completion/upterm.zsh_completion" dst: "/usr/share/zsh/site-functions/_upterm" ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. Please note we have a code of conduct, please follow it in all your interactions with the project. ## Pull Request Process 1. Ensure any install or build dependencies are removed before the end of the layer when doing a build. 2. Update the README.md with details of changes to the interface, this includes new environment variables, exposed ports, useful file locations and container parameters. 3. Increase the version numbers in any examples files and the README.md to the new version that this Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you do not have permission to do that, you may request the second reviewer to merge it for you. ## Code of Conduct ### Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ### Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ### Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ### Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ### Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [INSERT EMAIL ADDRESS]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ### Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: Dockerfile.uptermd ================================================ # syntax=docker/dockerfile:1 # Build stage - builds from source (used by Fly deployment) FROM golang:latest AS builder ARG TARGETOS ARG TARGETARCH ARG VERSION=0.0.0+dev ARG GIT_COMMIT=unknown ARG BUILD_DATE=unknown WORKDIR /src ENV CGO_ENABLED=0 RUN --mount=target=. \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ GOOS=$TARGETOS GOARCH=$TARGETARCH go install \ -ldflags="-s -w -X github.com/owenthereal/upterm/internal/version.Version=${VERSION} -X github.com/owenthereal/upterm/internal/version.GitCommit=${GIT_COMMIT} -X github.com/owenthereal/upterm/internal/version.Date=${BUILD_DATE}" \ ./cmd/... # Base runtime stage FROM gcr.io/distroless/static:nonroot AS base WORKDIR /app ENV PATH="/app:${PATH}" # sshd ws & prometheus EXPOSE 2222 8080 9090 # Fly deployment stage (builds from source) FROM base AS uptermd-fly COPY --from=builder /go/bin/uptermd /go/bin/uptermd-fly /app/ ENTRYPOINT ["uptermd-fly"] # Pre-built binary stage (used by GoReleaser) FROM base AS pre-built-binary ARG TARGETPLATFORM COPY ${TARGETPLATFORM}/uptermd /app/ ENTRYPOINT ["uptermd"] # Default stage FROM base AS uptermd COPY --from=builder /go/bin/uptermd /app/ ENTRYPOINT ["uptermd"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ SHELL=/bin/bash -o pipefail BIN_DIR ?= $(CURDIR)/bin export PATH := $(BIN_DIR):$(PATH) .PHONY: tools tools: rm -rf $(BIN_DIR) && mkdir -p $(BIN_DIR) # goreleaser GOBIN=$(BIN_DIR) go install github.com/goreleaser/goreleaser@latest .PHONY: generate generate: proto .PHONY: docs docs: rm -rf docs && mkdir docs rm -rf etc && mkdir -p etc/man/man1 && mkdir -p etc/completion XDG_STATE_HOME=/home/user/.local/state XDG_CONFIG_HOME=/home/user/.config XDG_RUNTIME_DIR=/run/user/1000 go run cmd/gendoc/main.go .PHONY: proto proto: docker run -v $(CURDIR)/server:/defs namely/protoc-all -f server.proto -l go --go-source-relative -o . docker run -v $(CURDIR)/host/api:/defs namely/protoc-all -f api.proto -l go --go-source-relative -o . .PHONY: build build: go build -o $(BIN_DIR)/upterm ./cmd/upterm go build -o $(BIN_DIR)/uptermd ./cmd/uptermd go build -o $(BIN_DIR)/uptermd-fly ./cmd/uptermd-fly .PHONY: install install: go install ./cmd/... TAG ?= latest REPO ?= ghcr.io/owenthereal/upterm/uptermd DOCKER_BUILD_FLAGS ?= --load .PHONY: docker_build docker_build: docker buildx build -t $(REPO):$(TAG) -f Dockerfile.uptermd $(DOCKER_BUILD_FLAGS) . GO_TEST_FLAGS ?= "" .PHONY: test test: go test $$(go list ./... | grep -v /e2e) -timeout=180s -coverprofile=c.out -covermode=atomic -count=1 -race -v $(GO_TEST_FLAGS) # E2E tests require tmux and UPTERM_E2E_SERVER env var # Example: UPTERM_E2E_SERVER=ssh://uptermd.upterm.dev:22 make test-e2e .PHONY: test-e2e test-e2e: go test ./internal/e2e/... -timeout=180s -count=1 -v $(GO_TEST_FLAGS) .PHONY: vet vet: docker run --rm -v $(CURDIR):/app:z -w /app golangci/golangci-lint:latest golangci-lint run -v --timeout 15m --fix DOCKER_REPO ?= ghcr.io/owenthereal/upterm/uptermd .PHONY: goreleaser goreleaser: DOCKER_REPO=$(DOCKER_REPO) goreleaser release --clean --snapshot --skip=publish ================================================ FILE: Procfile ================================================ web: bin/uptermd --ssh-addr 0.0.0.0:2222 --ws-addr 0.0.0.0:$PORT --node-addr ${HEROKU_PRIVATE_IP:-0.0.0.0}:2222 --network mem ================================================ FILE: README.md ================================================ # Upterm [Upterm](https://github.com/owenthereal/upterm) is an open-source tool enabling developers to share terminal sessions securely over the web. It’s perfect for remote pair programming, accessing computers behind NATs/firewalls, remote debugging, and more. This is a [blog post](https://owenou.com/upterm) to describe Upterm in depth. ## :movie_camera: Quick Demo [![demo](https://raw.githubusercontent.com/owenthereal/upterm/gh-pages/demo.gif)](https://asciinema.org/a/efeKPxxzKi3pkyu9LWs1yqdbB) ## :rocket: Getting Started ## Installation ### Mac ```console brew install --cask owenthereal/upterm/upterm ``` #### Migrating from Formula to Cask If you previously installed upterm using the Homebrew formula (without `--cask`), you'll need to migrate to the Cask version: ```console # Uninstall the old formula version brew uninstall upterm # Install the new Cask version brew install --cask owenthereal/upterm/upterm ``` **Note:** Running `brew upgrade` with the old formula installed will fail with an error. Follow the migration steps above to resolve this. ### Windows ```powershell scoop bucket add upterm https://github.com/owenthereal/scoop-upterm scoop install upterm ``` ### Standalone `upterm` can be easily installed as an executable. Download the latest [compiled binaries](https://github.com/owenthereal/upterm/releases) and put it in your executable path. ### From source ```console git clone https://github.com/owenthereal/upterm.git cd upterm go install ./cmd/upterm/... ``` ## :wrench: Basic Usage 1. Host starts a terminal session: ```console upterm host ``` 1. Host retrieves and shares the SSH connection string: ```console upterm session current ``` 1. Client connects using the shared string: ```console ssh TOKEN@uptermd.upterm.dev ``` ## :blue_book: Quick Reference Dive into more commands and advanced usage in the [documentation](docs/upterm.md). Below are some notable highlights: ### Command Execution Host a session with any desired command: ```console upterm host -- docker run --rm -ti ubuntu bash ``` ### Access Control Host a session with specified client public key(s) authorized to connect: ```console upterm host --authorized-keys PATH_TO_PUBLIC_KEY ``` Authorize specified GitHub, GitLab, SourceHut, Codeberg users with their corresponding public keys: ```console upterm host --github-user username upterm host --gitlab-user username upterm host --srht-user username upterm host --codeberg-user username ``` ### Force command Host a session initiating `tmux new -t pair-programming`, while ensuring clients join with `tmux attach -t pair-programming`. This mirrors functionality provided by tmate: ```console upterm host --force-command 'tmux attach -t pair-programming' -- tmux new -t pair-programming ``` ### File Transfer (SFTP/SCP) Clients can transfer files using standard `scp` or `sftp` commands. The connection details are shown when running `upterm session current`: ```console # Download a file from host scp -P PORT USER@HOST:/path/to/file.txt ./local/ # Upload a file to host scp -P PORT ./local/file.txt USER@HOST:/path/to/destination/ ``` **Security model:** - File transfers have the same access as the terminal session (clients can already access any file via the shell) - Without `--accept`, each file operation prompts the host for approval via a dialog - Use `--read-only` to restrict SFTP to downloads only (no uploads, deletes, or modifications) - Use `--no-sftp` to disable file transfers entirely ### Local TCP Forwarding Clients can use standard SSH local forwarding through a hosted session when the host opts in: ```console upterm host --allow-local-tcp-forwarding ssh -L 5555:127.0.0.1:8080 SESSION_SSH_USER@uptermd.upterm.dev ``` ### WebSocket Connection In scenarios where your host restricts ssh transport, establish a connection to `uptermd.upterm.dev` (or your self-hosted server) via WebSocket: ```console upterm host --server wss://uptermd.upterm.dev -- bash ``` Clients can connect to the host session via WebSocket as well: ```console ssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN@uptermd.upterm.dev:443 ``` ### Debug GitHub Actions `upterm` can be integrated with GitHub Actions to enable real-time SSH debugging, allowing you to interact directly with the runner system during workflow execution. This is achieved through [action-upterm](https://github.com/owenthereal/action-upterm), which sets up an `upterm` session within your CI pipeline. To get started, include `action-upterm` in your GitHub Actions workflow as follows: ```yaml name: CI on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup upterm session uses: owenthereal/action-upterm@v1 ``` This setup allows you to SSH into the workflow runner whenever you need to troubleshoot or inspect the execution environment. Find the SSH connection string in the `Checks` tab of your Pull Request or in the workflow logs. For comprehensive details on configuring and using this integration, visit the [action-upterm GitHub repo](https://github.com/owenthereal/action-upterm). ## :bulb: Tips ### Resolving Tmux Session Display Issue **Issue**: The command `upterm session current` does not display the current session when used within Tmux. **Cause**: This occurs because `upterm session current` requires the `UPTERM_ADMIN_SOCKET` environment variable, which is set in the specified command. Tmux, however, does not carry over environment variables not on its default list to any Tmux session unless instructed to do so ([Reference](http://man.openbsd.org/i386/tmux.1#GLOBAL_AND_SESSION_ENVIRONMENT)). **Solution**: To rectify this, add the following line to your `~/.tmux.conf`: ```conf set-option -ga update-environment " UPTERM_ADMIN_SOCKET" ``` ### Identifying Upterm Session **Issue**: It might be unclear whether your shell command is running in an upterm session, especially with common shell commands like `bash` or `zsh`. **Solution**: Use `upterm session current -o go-template` to customize your shell prompt with session info. Add to your `~/.bashrc` or `~/.zshrc`: ```bash # Show 🆙 emoji and connected client count when in upterm session export PS1='$(upterm session current -o go-template="🆙 {{.ClientCount}} " 2>/dev/null)'"$PS1" ``` **Template variables available** (Go templates use PascalCase field names): - `{{.SessionID}}` - Session ID - `{{.ClientCount}}` - Number of connected clients - `{{.Host}}` - Server host - `{{.Command}}` - Command being shared - `{{.ForceCommand}}` - Force command (if set) > **Note**: JSON output (`-o json`) uses camelCase keys (e.g., `sessionId`, `clientCount`). > > **Tip**: The same template mechanism can be used for terminal titles or other integrations. **Alternative** (simpler, without client count): ```bash export PS1="$([[ ! -z "${UPTERM_ADMIN_SOCKET}" ]] && echo -e '\xF0\x9F\x86\x99 ')$PS1" ``` ## :gear: How it works Upterm starts an SSH server (a.k.a. `sshd`) in the host machine and sets up a reverse SSH tunnel to a [Upterm server](https://github.com/owenthereal/upterm/tree/master/cmd/uptermd) (a.k.a. `uptermd`). Clients connect to a terminal session over the public internet via `uptermd` using `ssh` or `ssh` over WebSocket. ![upterm flowchart](https://raw.githubusercontent.com/owenthereal/upterm/gh-pages/upterm-flowchart.svg?sanitize=true) ## :hammer_and_wrench: Deployment ### Kubernetes You can deploy uptermd to a Kubernetes cluster. Install it with [helm](https://helm.sh): ```console helm repo add upterm https://upterm.dev helm repo update helm install uptermd upterm/uptermd ``` ### Fly.io The cheapest way to deploy a worry-free [Upterm server](https://github.com/owenthereal/upterm/tree/master/cmd/uptermd) (a.k.a. `uptermd`) is to use [Fly.io](https://fly.io). Fly offers a generous free tier and excellent global performance. The official uptermd community server is hosted on Fly. 1. Install the Fly CLI and authenticate: ```console curl -L https://fly.io/install.sh | sh flyctl auth login ``` 1. Copy and customize the [`fly.example.toml`](./fly.example.toml) file to `fly.toml` for your deployment configuration. 1. Deploy your uptermd server: ```console flyctl deploy ``` Your uptermd server will be available at `your-app-name.fly.dev`. You can connect using either SSH or WebSocket protocols. ### Heroku You can deploy an [Upterm server](https://github.com/owenthereal/upterm/tree/master/cmd/uptermd) (a.k.a. `uptermd`) to [Heroku](https://heroku.com). Note that Heroku discontinued their free tier in November 2022, so this option now requires paid plans. You can deploy with one click of the following button: [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) You can also automate the deployment with [Heroku Terraform](https://devcenter.heroku.com/articles/using-terraform-with-heroku). The Heroku Terraform scripts are in the [terraform/heroku folder](./terraform/heroku). A [util script](./bin/heroku-install) is provided for your convenience to automate everything: ```console git clone https://github.com/owenthereal/upterm cd upterm ``` Provision uptermd in Heroku Common Runtime. Follow instructions. ```console bin/heroku-install ``` Provision uptermd in Heroku Private Spaces. Follow instructions. ```console TF_VAR_heroku_region=REGION TF_VAR_heroku_space=SPACE_NAME TF_VAR_heroku_team=TEAM_NAME bin/heroku-install ``` You **must** use WebSocket as the protocol for a Heroku-deployed Uptermd server because the platform only support HTTP/HTTPS routing. This is how you host a session and join a session: Use the Heroku-deployed Uptermd server via WebSocket ```console upterm host --server wss://YOUR_HEROKU_APP_URL -- YOUR_COMMAND ``` A client connects to the host session via WebSocket ```console ssh -o ProxyCommand='upterm proxy wss://TOKEN@YOUR_HEROKU_APP_URL' TOKEN@YOUR_HEROKU_APP_URL:443 ``` ### Digital Ocean There is an util script that makes provisioning [Digital Ocean Kubernetes](https://www.digitalocean.com/products/kubernetes) and an Upterm server easier: ```bash TF_VAR_do_token=$DO_PAT \ TF_VAR_uptermd_host=uptermd.upterm.dev \ TF_VAR_uptermd_acme_email=YOUR_EMAIL \ TF_VAR_uptermd_helm_repo=http://localhost:8080 \ TF_VAR_uptermd_host_keys_dir=PATH_TO_HOST_KEYS \ bin/do-install ``` ### Systemd A hardened systemd service is provided in `systemd/uptermd.service`. You can use it to easily run a secured `uptermd` on your machine: ```console cp systemd/uptermd.service /etc/systemd/system/uptermd.service systemctl daemon-reload systemctl start uptermd ``` ### Traefik Below is an example `docker-compose` configuration for deploying `uptermd` behind [Traefik](https://doc.traefik.io/traefik/), including support for both SSH and WebSocket connections: ```yaml services: upterm: build: context: https://github.com/owenthereal/upterm.git dockerfile: Dockerfile.uptermd labels: - "traefik.enable=true" - "traefik.docker.network=web" # SSH over TCP (port 2222) - "traefik.tcp.services.uptermd.loadbalancer.server.port=2222" - "traefik.tcp.services.uptermd.loadbalancer.proxyProtocol.version=2" # required for real IP forwarding over TCP - "traefik.tcp.routers.uptermd.service=uptermd" - "traefik.tcp.routers.uptermd.rule=HostSNI(`*`)" - "traefik.tcp.routers.uptermd.entrypoints=uptermd" # WebSocket over HTTPS (port 8443) - "traefik.http.services.uptermd-wss.loadbalancer.server.port=8443" - "traefik.http.routers.uptermd-wss.service=uptermd-wss" - "traefik.http.routers.uptermd-wss.rule=Host(`upterm.example.com`)" # edit as needed - "traefik.http.routers.uptermd-wss.entrypoints=websecure" - "traefik.http.routers.uptermd-wss.tls.certresolver=" command: - --ssh-addr=0.0.0.0:2222 - --ws-addr=0.0.0.0:8443 - --ssh-proxy-protocol networks: - web networks: web: external: true ``` **Important notes:** - **Proxy Protocol:** The `--ssh-proxy-protocol` flag (or `UPTERMD_SSH_PROXY_PROTOCOL=true` environment variable) tells `uptermd` to expect the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) header on incoming SSH connections. This is essential when using Traefik (or other TCP proxies like HAProxy or AWS ELB) to preserve the real client IP address. **If you enable `--ssh-proxy-protocol`, all incoming SSH connections must come through a proxy that supports and is configured to use the PROXY protocol. Direct SSH connections will fail, as `uptermd` will expect the protocol header.** - **Entrypoints:** Make sure to configure the appropriate [Traefik entrypoints](https://doc.traefik.io/traefik/routing/entrypoints/). This example uses two: one for SSH (`uptermd` on port `2222`) and one for WebSocket/HTTPS (`websecure` on port `443`). - **WebSocket:** The WebSocket service allows clients to connect to `uptermd` over HTTPS, which is useful in restrictive network environments. - **Certificates:** Replace `` with your actual Traefik certificate resolver for TLS. For more details on Traefik TCP and HTTP routing, see the [Traefik documentation](https://doc.traefik.io/traefik/routing/overview/). ### Restricting Host Registration By default, any SSH client that can reach `uptermd` can register a session as a host. For private or invite-only deployments, the `--authorized-keys` flag (or `UPTERMD_AUTHORIZED_KEYS` environment variable) restricts host registration to a specific set of public keys. This mirrors OpenSSH's [`AuthorizedKeysFile`](https://man.openbsd.org/sshd_config#AuthorizedKeysFile) directive. ```console uptermd --authorized-keys /etc/uptermd/authorized_keys ``` The flag accepts standard `authorized_keys`-formatted files (one key per line, comments allowed) and may be repeated to compose keys from multiple sources: ```console uptermd --authorized-keys /etc/uptermd/team.keys --authorized-keys /etc/uptermd/ops.keys ``` Files are read once at startup; restart `uptermd` to pick up edits. Joiners (clients connecting to a session) are unaffected — they continue to be authorized by the host's own `authorized_keys`. For the Helm chart, populate the `authorized_keys` value: ```yaml authorized_keys: - "ssh-ed25519 AAAA... alice@laptop" - "ssh-ed25519 BBBB... bob@desktop" ``` ## :chart_with_upwards_trend: Monitoring `uptermd` exposes Prometheus metrics at the `/metrics` endpoint when configured with `--metric-addr` (or `UPTERMD_METRIC_ADDR` environment variable). Available metrics: - `routing_connections_count` (Counter) - Total number of SSH connections accepted - `routing_active_connections_count` (Gauge) - Current number of active SSH connections - `routing_connection_duration_seconds` (Histogram) - Connection duration in seconds - `routing_errors_count` (Counter) - Total number of connection errors - `routing_connection_timeout_count` (Counter) - Number of connections that timed out during establishment ## :balance_scale: Comparison with Prior Arts Upterm stands as a modern alternative to [Tmate](https://github.com/tmate-io/tmate). Tmate originates as a fork from an older iteration of Tmux, extending terminal sharing capabilities atop Tmux 2.x. However, Tmate has no plans to align with the latest Tmux updates, compelling Tmate & Tmux users to manage two separate configurations. For instance, the necessity to [bind identical keys twice, conditionally](https://github.com/tmate-io/tmate/issues/108). On the flip side, Upterm is architected from the ground up to be an independent solution, not a fork. It embodies the idea of connecting the input & output of any shell command between a host and its clients, transcending beyond merely `tmux`. This paves the way for securely sharing terminal sessions utilizing containers. Written in Go, Upterm is more hack-friendly compared to Tmate, which is crafted in C, akin to Tmux. The seamless compilation of Upterm CLI and server (`uptermd`) into a single binary facilitates swift [deployment of your pairing server](#hammer_and_wrench-deployment) across any cloud environment, devoid of dependencies. ## License [Apache 2.0](https://github.com/owenthereal/upterm/blob/master/LICENSE) ================================================ FILE: app.json ================================================ { "name": "Upterm", "keywords": [ "golang", "terminal", "upterm", "uptermd" ], "website": "https://upterm.dev", "success_url": "/getting-started", "description": "Secure Terminal Sharing", "repository": "https://github.com/owenthereal/upterm", "buildpacks": [ { "url": "heroku/go" } ] } ================================================ FILE: charts/uptermd/.helmignore ================================================ # Patterns to ignore when building packages. # This supports shell glob matching, relative path matching, and # negation (prefixed with !). Only one pattern per line. .DS_Store # Common VCS dirs .git/ .gitignore .bzr/ .bzrignore .hg/ .hgignore .svn/ # Common backup files *.swp *.bak *.tmp *.orig *~ # Various IDEs .project .idea/ *.tmproj .vscode/ ================================================ FILE: charts/uptermd/Chart.yaml ================================================ apiVersion: v2 name: uptermd description: Secure Terminal Sharing type: application version: 0.2.0 appVersion: 0.14.3 home: https://upterm.dev sources: - https://github.com/owenthereal/upterm dependencies: maintainers: - name: Owen Ou email: o@owenou.com url: https://github.com/owenthereal ================================================ FILE: charts/uptermd/templates/NOTES.txt ================================================ Host a terminal session by running these commands: {{- if contains "NodePort" .Values.service.type }} export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") upterm host --server ssh://$NODE_IP:22 -- bash {{- else if contains "LoadBalancer" .Values.service.type }} NOTE: It may take a few minutes for the LoadBalancer IP to be available. You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "upterm.fullname" . }}' export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "upterm.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") upterm host --server ssh://$SERVICE_IP:22 -- bash {{- else if contains "ClusterIP" .Values.service.type }} export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "upterm.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 2222:22 upterm host --server ssh://localhost:2222 -- bash {{- end }} ================================================ FILE: charts/uptermd/templates/_helpers.tpl ================================================ {{/* vim: set filetype=mustache: */}} {{/* Expand the name of the chart. */}} {{- define "upterm.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} {{- define "upterm.fullname" -}} {{- if .Values.fullnameOverride }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- else }} {{- $name := default .Chart.Name .Values.nameOverride }} {{- if contains $name .Release.Name }} {{- .Release.Name | trunc 63 | trimSuffix "-" }} {{- else }} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} {{- end }} {{- end }} {{- end }} {{/* Create chart name and version as used by the chart label. */}} {{- define "upterm.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Common labels */}} {{- define "upterm.labels" -}} helm.sh/chart: {{ include "upterm.chart" . }} {{ include "upterm.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} {{/* Selector labels */}} {{- define "upterm.selectorLabels" -}} app.kubernetes.io/name: {{ include "upterm.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* Create the name of the service account to use */}} {{- define "upterm.serviceAccountName" -}} {{- if .Values.serviceAccount.create }} {{- default (include "upterm.fullname" .) .Values.serviceAccount.name }} {{- else }} {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} ================================================ FILE: charts/uptermd/templates/configmap.yaml ================================================ {{- if gt (len .Values.authorized_keys) 0 }} apiVersion: v1 kind: ConfigMap metadata: name: {{ include "upterm.fullname" . }} labels: {{- include "upterm.labels" . | nindent 4 }} data: authorized_keys: |- {{- range .Values.authorized_keys }} {{- . | nindent 4 }} {{- end }} {{- end }} ================================================ FILE: charts/uptermd/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "upterm.fullname" . }} labels: {{- include "upterm.labels" . | nindent 4 }} spec: {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} {{- end }} selector: matchLabels: {{- include "upterm.selectorLabels" . | nindent 6 }} template: metadata: {{- with .Values.podAnnotations }} annotations: {{- toYaml . | nindent 8 }} {{- end }} labels: {{- include "upterm.selectorLabels" . | nindent 8 }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} serviceAccountName: {{ include "upterm.serviceAccountName" . }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: - name: {{ .Chart.Name }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} args: - --ssh-addr - $(POD_IP):22 {{- if .Values.websocket.enabled }} - --ws-addr - $(POD_IP):80 {{- end }} {{- if gt (len .Values.authorized_keys) 0 }} - --authorized-keys - /etc/uptermd/authorized_keys {{- end }} - --node-addr - $(POD_IP):22 - --hostname - {{ .Values.hostname }} {{- range $key, $val := .Values.host_keys }} {{ if hasSuffix ".pub" $key }} {{ else }} - --private-key - /host-keys/{{ $key }} {{- end }} {{- end }} - --network - mem - --metric-addr - $(POD_IP):9090 {{- if .Values.debug }} - --debug {{- end }} env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP ports: - containerPort: 22 name: sshd {{- if .Values.websocket.enabled }} - containerPort: 80 name: ws {{- end }} - containerPort: 9090 name: exporter readinessProbe: tcpSocket: port: 22 periodSeconds: 10 livenessProbe: tcpSocket: port: 22 periodSeconds: 20 resources: {{- toYaml .Values.resources | nindent 12 }} volumeMounts: - mountPath: /host-keys name: host-keys {{- if gt (len .Values.authorized_keys) 0 }} - mountPath: /etc/uptermd name: authorized-keys {{- end }} volumes: - name: host-keys secret: secretName: {{ include "upterm.fullname" . }} defaultMode: 0600 {{- if gt (len .Values.authorized_keys) 0 }} - name: authorized-keys configMap: name: {{ include "upterm.fullname" . }} {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.tolerations }} tolerations: {{- toYaml . | nindent 8 }} {{- end }} ================================================ FILE: charts/uptermd/templates/hpa.yaml ================================================ {{- if .Values.autoscaling.enabled }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: {{ include "upterm.fullname" . }} labels: {{- include "upterm.labels" . | nindent 4 }} spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: {{ include "upterm.fullname" . }} minReplicas: {{ .Values.autoscaling.minReplicas }} maxReplicas: {{ .Values.autoscaling.maxReplicas }} metrics: {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} - type: Resource resource: name: cpu target: type: Utilization averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} {{- end }} {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} - type: Resource resource: name: memory target: type: Utilization averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} {{- end }} {{- end }} ================================================ FILE: charts/uptermd/templates/ingress.yaml ================================================ {{- if .Values.websocket.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "upterm.fullname" . }} labels: {{- include "upterm.labels" . | nindent 4 }} annotations: kubernetes.io/ingress.class: {{ .Values.websocket.ingress_nginx_ingress_class }} nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/limit-connections: "4" nginx.ingress.kubernetes.io/limit-rps: "5" cert-manager.io/issuer: {{ include "upterm.fullname" . }}-letsencrypt {{- with .Values.websocket.ingress.annotations }} {{- toYaml . | nindent 4 }} {{- end }} spec: tls: - hosts: - {{ .Values.hostname }} secretName: {{ .Values.hostname | replace "." "-" }}-tls rules: - host: {{ .Values.hostname }} http: paths: - path: / pathType: Prefix backend: service: name: {{ include "upterm.fullname" . }} port: number: 80 {{- end }} ================================================ FILE: charts/uptermd/templates/issuer.yaml ================================================ {{- if .Values.websocket.enabled }} apiVersion: cert-manager.io/v1 kind: Issuer metadata: name: {{ include "upterm.fullname" . }}-letsencrypt labels: {{- include "upterm.labels" . | nindent 4 }} spec: acme: # The ACME server URL server: https://acme-v02.api.letsencrypt.org/directory # Email address used for ACME registration email: {{ .Values.websocket.cert_manager_acme_email }} # Name of a secret used to store the ACME account private key privateKeySecretRef: name: {{ include "upterm.fullname" . }}-letsencrypt # Enable the HTTP-01 challenge provider solvers: - http01: ingress: class: {{ .Values.websocket.ingress_nginx_ingress_class }} {{- end }} ================================================ FILE: charts/uptermd/templates/secret.yaml ================================================ apiVersion: v1 kind: Secret metadata: name: {{ include "upterm.fullname" . }} labels: {{- include "upterm.labels" . | nindent 4 }} type: Opaque data: {{- range $key, $val := .Values.host_keys }} {{ $key }}: {{ $val }} {{- end }} ================================================ FILE: charts/uptermd/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: {{ include "upterm.fullname" . }} labels: {{- include "upterm.labels" . | nindent 4 }} {{- with .Values.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: type: {{ .Values.service.type }} ports: - port: 22 protocol: TCP targetPort: 22 name: sshd {{- if .Values.websocket.enabled }} - port: 80 protocol: TCP targetPort: 80 name: ws {{- end }} selector: {{- include "upterm.selectorLabels" . | nindent 4 }} ================================================ FILE: charts/uptermd/templates/serviceaccount.yaml ================================================ {{- if .Values.serviceAccount.create -}} apiVersion: v1 kind: ServiceAccount metadata: name: {{ include "upterm.serviceAccountName" . }} labels: {{- include "upterm.labels" . | nindent 4 }} {{- with .Values.serviceAccount.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} {{- end }} ================================================ FILE: charts/uptermd/templates/tests/test-connection.yaml ================================================ apiVersion: v1 kind: Pod metadata: name: "{{ include "upterm.fullname" . }}-test-connection" labels: {{- include "upterm.labels" . | nindent 4 }} annotations: "helm.sh/hook": test-success spec: containers: - name: wget image: busybox command: ['wget'] args: ['{{ include "upterm.fullname" . }}:{{ .Values.service.port }}'] restartPolicy: Never ================================================ FILE: charts/uptermd/values.yaml ================================================ # Default values for upterm. # This is a YAML-formatted file. # Declare variables to be passed into your templates. replicaCount: 1 image: repository: ghcr.io/owenthereal/upterm/uptermd pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. tag: latest imagePullSecrets: [] nameOverride: "" fullnameOverride: "" debug: false serviceAccount: # Specifies whether a service account should be created create: true # Annotations to add to the service account annotations: {} # The name of the service account to use. # If not set and create is true, a name is generated using the fullname template name: "" podAnnotations: {} podSecurityContext: {} # fsGroup: 2000 securityContext: {} # capabilities: # drop: # - ALL # readOnlyRootFilesystem: true # runAsNonRoot: true # runAsUser: 1000 service: type: ClusterIP # Set to LoadBalancer to accept traffic from outside the cluster # type: LoadBalancer annotations: {} resources: limits: cpu: 100m memory: 512Mi requests: cpu: 100m memory: 512Mi autoscaling: enabled: false minReplicas: 1 maxReplicas: 10 targetCPUUtilizationPercentage: 80 targetMemoryUtilizationPercentage: 80 nodeSelector: {} tolerations: [] affinity: {} host_keys: {} # SSH public keys (in `authorized_keys` line format, one entry per list item) # permitted to register as hosts on this uptermd. When empty, any client may # register. Edits require restarting uptermd to take effect. # Example: # authorized_keys: # - "ssh-ed25519 AAAA... alice@laptop" # - "ssh-ed25519 BBBB... bob@desktop" authorized_keys: [] hostname: my-upterm-host # Require ingress-nginx & cert-manager websocket: enabled: false cert_manager_acme_email: your_email ingress_nginx_ingress_class: nginx ingress: annotations: {} ================================================ FILE: cmd/gendoc/main.go ================================================ package main import ( "os" "github.com/owenthereal/upterm/cmd/upterm/command" "github.com/owenthereal/upterm/internal/logging" "github.com/owenthereal/upterm/internal/version" "github.com/spf13/cobra/doc" ) func main() { logger := logging.Must(logging.Console()).With("component", "gendoc") defer func() { _ = logger.Close() }() // Note: XDG environment variables should be set externally before running this command // to generate docs with generic paths instead of machine-specific paths. // See Makefile 'docs' target for proper environment variable setup. rootCmd := command.Root() if err := doc.GenMarkdownTree(rootCmd, "./docs"); err != nil { logger.Error("failed generating markdown docs", "error", err) os.Exit(1) } header := &doc.GenManHeader{ Title: "UPTERM", Section: "1", Source: "Upterm " + version.String(), Manual: "Upterm Manual", } if err := doc.GenManTree(rootCmd, header, "./etc/man/man1"); err != nil { logger.Error("failed generating man pages", "error", err) os.Exit(1) } if err := rootCmd.GenBashCompletionFile("./etc/completion/upterm.bash_completion.sh"); err != nil { logger.Error("failed generating bash completion", "error", err) os.Exit(1) } if err := rootCmd.GenZshCompletionFile("./etc/completion/upterm.zsh_completion"); err != nil { logger.Error("failed generating zsh completion", "error", err) os.Exit(1) } } ================================================ FILE: cmd/upterm/command/config.go ================================================ package command import ( "fmt" "os" "os/exec" "runtime" "github.com/owenthereal/upterm/utils" "github.com/spf13/cobra" "github.com/spf13/viper" ) func configCmd() *cobra.Command { configPath := utils.UptermConfigFilePath() cmd := &cobra.Command{ Use: "config", Short: "Manage upterm configuration", Long: fmt.Sprintf(`Manage upterm configuration file. Config file: %s This follows the XDG Base Directory Specification. Configuration priority (highest to lowest): 1. Command-line flags 2. Environment variables (UPTERM_ prefix) 3. Config file 4. Default values`, configPath), } cmd.AddCommand(configPathCmd()) cmd.AddCommand(configViewCmd()) cmd.AddCommand(configEditCmd()) return cmd } func configPathCmd() *cobra.Command { configPath := utils.UptermConfigFilePath() cmd := &cobra.Command{ Use: "path", Short: "Show the path to the config file", Long: fmt.Sprintf(`Show the path to the config file. Config file: %s The config file is optional and created manually by users.`, configPath), Example: ` # Show config file path: upterm config path # Create config file directory: mkdir -p "$(dirname "$(upterm config path)")"`, RunE: configPathRunE, } return cmd } func configViewCmd() *cobra.Command { configPath := utils.UptermConfigFilePath() cmd := &cobra.Command{ Use: "view", Short: "View the config file contents", Long: fmt.Sprintf(`View the config file contents. Config file: %s If the config file exists, this command displays its contents. If it doesn't exist, this command shows an example config file that you can use as a template.`, configPath), Example: ` # View current config: upterm config view # View and save as new config: upterm config view > "$(upterm config path)"`, RunE: configViewRunE, } return cmd } func configEditCmd() *cobra.Command { configPath := utils.UptermConfigFilePath() cmd := &cobra.Command{ Use: "edit", Short: "Edit the config file", Long: fmt.Sprintf(`Edit the config file in your default editor. Config file: %s This command opens the config file in your editor (determined by $VISUAL, $EDITOR, or a sensible default). If the config file doesn't exist, it creates a template with example settings and comments. The config directory is created automatically if it doesn't exist.`, configPath), Example: ` # Edit config file: upterm config edit # Use a specific editor: EDITOR=nano upterm config edit`, RunE: configEditRunE, } return cmd } func configPathRunE(c *cobra.Command, args []string) error { configPath := utils.UptermConfigFilePath() fmt.Println(configPath) return nil } func configViewRunE(c *cobra.Command, args []string) error { configPath := utils.UptermConfigFilePath() // Check if file exists if _, err := os.Stat(configPath); os.IsNotExist(err) { // Show example config fmt.Println("# Config file does not exist. Example config:") fmt.Println() fmt.Print(exampleConfig()) return nil } // Read and display file content, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("failed to read config file: %w", err) } fmt.Print(string(content)) return nil } func configEditRunE(c *cobra.Command, args []string) error { configPath := utils.UptermConfigFilePath() configDir := utils.UptermConfigDir() // Create config directory if it doesn't exist if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } // Create example config if file doesn't exist if _, err := os.Stat(configPath); os.IsNotExist(err) { if err := os.WriteFile(configPath, []byte(exampleConfig()), 0600); err != nil { return fmt.Errorf("failed to create config file: %w", err) } } // Determine editor to use editor := getEditor() // Open editor cmd := exec.Command(editor, configPath) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("failed to open editor: %w", err) } // Validate config after editing if err := validateConfig(configPath); err != nil { fmt.Fprintf(os.Stderr, "Warning: config file has syntax errors: %v\n", err) fmt.Fprintf(os.Stderr, "Edit again with 'upterm config edit' or view with 'upterm config view'.\n") } return nil } // getEditor returns the editor to use, checking $VISUAL, $EDITOR, then defaults. func getEditor() string { // Check $VISUAL first (for full-screen editors) if editor := os.Getenv("VISUAL"); editor != "" { return editor } // Check $EDITOR (for line editors) if editor := os.Getenv("EDITOR"); editor != "" { return editor } // Platform-specific defaults switch runtime.GOOS { case "windows": return "notepad" default: // Unix-like systems: prefer nano for better UX, fall back to vi if _, err := exec.LookPath("nano"); err == nil { return "nano" } return "vi" } } // validateConfig validates the config file by attempting to parse it. func validateConfig(path string) error { v := viper.New() v.SetConfigFile(path) return v.ReadInConfig() } // exampleConfig returns an example config file with comments. func exampleConfig() string { return `# Upterm Configuration File # # This file follows the XDG Base Directory Specification. # Settings here are overridden by environment variables (UPTERM_*) and command-line flags. # # Configuration priority (highest to lowest): # 1. Command-line flags # 2. Environment variables (UPTERM_* prefix) # 3. This config file # 4. Default values # Debug logging (default: false) # When enabled, writes debug-level logs to the log file. # debug: true # Default server address for hosting sessions (default: ssh://uptermd.upterm.dev:22) # Supported protocols: ssh, ws, wss # server: ssh://uptermd.upterm.dev:22 # Force a specific command for clients (default: none) # When set, clients cannot run arbitrary commands. # Use YAML array syntax: ["command", "arg1", "arg2"] # force-command: ["/bin/bash", "-l"] # Path to authorized_keys file for client authentication (default: none) # authorized-keys: /path/to/authorized_keys # Paths to private key files (default: generates ephemeral key) # private-key: # - /path/to/private/key1 # - /path/to/private/key2 # Read-only mode (default: false) # When enabled, clients can view but not interact with the session. # read-only: false # Allow clients to use SSH local TCP forwarding (ssh -L) (default: false) # When enabled, clients can reach TCP destinations visible to the host. # Cannot be combined with read-only. # allow-local-tcp-forwarding: false # Auto-accept clients without confirmation (default: false) # WARNING: Only use this in trusted environments. # accept: false # Hide client IP addresses from logs and display (default: false) # hide-client-ip: false ` } ================================================ FILE: cmd/upterm/command/host.go ================================================ package command import ( "context" "errors" "fmt" "log/slog" "net" "net/url" "os" "path/filepath" "time" tea "github.com/charmbracelet/bubbletea" "github.com/gen2brain/beeep" "github.com/google/shlex" "github.com/hashicorp/go-multierror" "github.com/owenthereal/upterm/cmd/upterm/command/internal/tui" "github.com/owenthereal/upterm/host" "github.com/owenthereal/upterm/host/api" "github.com/owenthereal/upterm/host/sftp" "github.com/owenthereal/upterm/icon" uptermctx "github.com/owenthereal/upterm/internal/context" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" ) // UserDiscardedError represents a user's intentional choice to discard the session type UserDiscardedError struct{} func (e UserDiscardedError) Error() string { return "session discarded by user" } // UserInterruptedError represents a user's Ctrl+C interruption type UserInterruptedError struct{} func (e UserInterruptedError) Error() string { return "interrupted by user" } // SilentError wraps an error that has already been displayed to the user. // main.go checks for this type to avoid duplicate logging. type SilentError struct { Err error } func (e SilentError) Error() string { return e.Err.Error() } func (e SilentError) Unwrap() error { return e.Err } var ( flagServer string flagForceCommand string flagPrivateKeys []string flagKnownHostsFilename string flagAuthorizedKeys string flagCodebergUsers []string flagGitHubUsers []string flagGitLabUsers []string flagSourceHutUsers []string flagReadOnly bool flagAccept bool flagSkipHostKeyCheck bool flagNoSFTP bool flagAllowLocalTCPForwarding bool ) func hostCmd() *cobra.Command { cmd := &cobra.Command{ Use: "host", Short: "Host a terminal session", Long: `Host a terminal session via a reverse SSH tunnel to the Upterm server. The session links the host and client IO to a command's IO. Authentication with the Upterm server uses private keys in this order: 1. Private key files: ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, ~/.ssh/id_rsa 2. SSH Agent keys 3. Auto-generated ephemeral key (if no keys found) To authorize client connections, use --authorized-keys to specify an authorized_keys file containing client public keys.`, Example: ` # Host a terminal session running $SHELL, attaching client's IO to the host's: upterm host # Accept client connections automatically without prompts: upterm host --accept # Host a terminal session allowing only specified public key(s) to connect: upterm host --authorized-keys PATH_TO_AUTHORIZED_KEY_FILE # Host a session executing a custom command: upterm host -- docker run --rm -ti ubuntu bash # Host a 'tmux new -t pair-programming' session, forcing clients to join with 'tmux attach -t pair-programming': upterm host --force-command 'tmux attach -t pair-programming' -- tmux new -t pair-programming # Allow clients to use local TCP forwarding (ssh -L) through the hosted session: upterm host --allow-local-tcp-forwarding # Use a different Uptermd server, hosting a session via WebSocket: upterm host --server wss://YOUR_UPTERMD_SERVER -- YOUR_COMMAND`, PreRunE: validateShareRequiredFlags, RunE: shareRunE, } homeDir, err := os.UserHomeDir() if err != nil { slog.Error("error getting user home directory", "error", err) os.Exit(1) } cmd.PersistentFlags().StringVarP(&flagServer, "server", "", "ssh://uptermd.upterm.dev:22", "Specify the upterm server address (required). Supported protocols: ssh, ws, wss.") cmd.PersistentFlags().StringVarP(&flagForceCommand, "force-command", "f", "", "Enforce a specified command for clients to join, and link the command's input/output to the client's terminal.") cmd.PersistentFlags().StringSliceVarP(&flagPrivateKeys, "private-key", "i", defaultPrivateKeys(homeDir), "Specify private key files for public key authentication with the upterm server (required).") cmd.PersistentFlags().StringVarP(&flagKnownHostsFilename, "known-hosts", "", defaultKnownHost(homeDir), "Specify a file containing known keys for remote hosts (required).") cmd.PersistentFlags().StringVar(&flagAuthorizedKeys, "authorized-keys", "", "Specify a authorize_keys file listing authorized public keys for connection.") cmd.PersistentFlags().StringSliceVar(&flagCodebergUsers, "codeberg-user", nil, "Authorize specified Codeberg users by allowing their public keys to connect.") cmd.PersistentFlags().StringSliceVar(&flagGitHubUsers, "github-user", nil, "Authorize specified GitHub users by allowing their public keys to connect. Configure GitHub CLI environment variables as needed; see https://cli.github.com/manual/gh_help_environment for details.") cmd.PersistentFlags().StringSliceVar(&flagGitLabUsers, "gitlab-user", nil, "Authorize specified GitLab users by allowing their public keys to connect.") cmd.PersistentFlags().StringSliceVar(&flagSourceHutUsers, "srht-user", nil, "Authorize specified SourceHut users by allowing their public keys to connect.") cmd.PersistentFlags().BoolVar(&flagAccept, "accept", false, "Automatically accept client connections without prompts.") cmd.PersistentFlags().BoolVarP(&flagReadOnly, "read-only", "r", false, "Host a read-only session, preventing client interaction. Also restricts SFTP to download-only.") cmd.PersistentFlags().BoolVar(&flagHideClientIP, "hide-client-ip", false, "Hide client IP addresses from output (auto-enabled in CI environments).") cmd.PersistentFlags().BoolVar(&flagSkipHostKeyCheck, "skip-host-key-check", false, "Automatically accept unknown server host keys and add them to known_hosts (similar to SSH's StrictHostKeyChecking=accept-new). This bypasses host key verification for new connections.") cmd.PersistentFlags().BoolVar(&flagNoSFTP, "no-sftp", false, "Disable file transfer via SFTP/SCP. By default, clients can transfer files with the same access as the terminal session.") cmd.PersistentFlags().BoolVar(&flagAllowLocalTCPForwarding, "allow-local-tcp-forwarding", false, "Allow clients to use SSH local TCP forwarding (ssh -L) through the hosted session, reaching TCP destinations visible to the host.") return cmd } func validateShareRequiredFlags(c *cobra.Command, args []string) error { var result error if flagReadOnly && flagAllowLocalTCPForwarding { result = multierror.Append(result, fmt.Errorf("--read-only and --allow-local-tcp-forwarding cannot be used together: a read-only session must not permit network pivoting through the host")) } if flagServer == "" { result = multierror.Append(result, fmt.Errorf("missing flag --server")) } else { u, err := url.Parse(flagServer) if err != nil { result = multierror.Append(result, fmt.Errorf("error parsing server URL: %w", err)) } if u != nil { if u.Scheme != "ssh" && u.Scheme != "ws" && u.Scheme != "wss" { result = multierror.Append(result, fmt.Errorf("unsupported server protocol %s", u.Scheme)) } if u.Scheme == "ssh" { _, _, err := net.SplitHostPort(u.Host) if err != nil { result = multierror.Append(result, err) } } // set default ports for ws or wss if u.Scheme == "ws" && u.Port() == "" { u.Host = u.Host + ":80" flagServer = u.String() } if u.Scheme == "wss" && u.Port() == "" { u.Host = u.Host + ":443" flagServer = u.String() } } } return result } func shareRunE(c *cobra.Command, args []string) error { // Early TTY check: if interactive confirmation is needed but no TTY is available, fail fast // before making any network connections. This provides clear feedback and avoids orphan sessions. if !flagAccept && !tui.IsTTY() { c.SilenceUsage = true c.SilenceErrors = true fmt.Fprintln(os.Stderr, "Error: interactive confirmation requires a terminal (TTY)") fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "To run in non-interactive environments (CI, scripts, etc.), use --accept:") fmt.Fprintln(os.Stderr, " upterm host --accept [command]") return SilentError{Err: errors.New("no TTY available")} } var err error if len(args) == 0 { shellCmd := getDefaultShell() args, err = shlex.Split(shellCmd) if err != nil { return err } if len(args) == 0 { return fmt.Errorf("no command is specified") } } var forceCommand []string if flagForceCommand != "" { forceCommand, err = shlex.Split(flagForceCommand) if err != nil { return fmt.Errorf("error parsing command %s: %w", flagForceCommand, err) } } logger := uptermctx.Logger(c.Context()) if logger == nil { return fmt.Errorf("logger not available") } var authorizedKeys []*host.AuthorizedKey if flagAuthorizedKeys != "" { aks, err := host.AuthorizedKeysFromFile(flagAuthorizedKeys) if err != nil { return fmt.Errorf("error reading authorized keys: %w", err) } authorizedKeys = append(authorizedKeys, aks) } if flagCodebergUsers != nil { codebergUserKeys, err := host.CodebergUserAuthorizedKeys(flagCodebergUsers) if err != nil { return fmt.Errorf("error reading Codeberg user keys: %w", err) } authorizedKeys = append(authorizedKeys, codebergUserKeys...) } if flagGitHubUsers != nil { gitHubUserKeys, err := host.GitHubUserAuthorizedKeys(flagGitHubUsers, logger.Logger) if err != nil { return fmt.Errorf("error reading GitHub user keys: %w", err) } authorizedKeys = append(authorizedKeys, gitHubUserKeys...) } if flagGitLabUsers != nil { gitLabUserKeys, err := host.GitLabUserAuthorizedKeys(flagGitLabUsers) if err != nil { return fmt.Errorf("error reading GitLab user keys: %w", err) } authorizedKeys = append(authorizedKeys, gitLabUserKeys...) } if flagSourceHutUsers != nil { sourceHutUserKeys, err := host.SourceHutUserAuthorizedKeys(flagSourceHutUsers) if err != nil { return fmt.Errorf("error reading SourceHut user keys: %w", err) } authorizedKeys = append(authorizedKeys, sourceHutUserKeys...) } signers, cleanup, err := host.Signers(flagPrivateKeys) if err != nil { return fmt.Errorf("error reading private keys: %w", err) } if cleanup != nil { defer cleanup() } var hkcb ssh.HostKeyCallback if flagSkipHostKeyCheck { hkcb, err = host.NewAutoAcceptingHostKeyCallback(os.Stdout, flagKnownHostsFilename) } else { hkcb, err = host.NewPromptingHostKeyCallback(os.Stdin, os.Stdout, flagKnownHostsFilename) } if err != nil { return err } // Set up SFTP permission checker based on --accept flag var sftpPermissionChecker sftp.PermissionChecker if flagAccept { sftpPermissionChecker = &AutoAllowPermissionChecker{} } else { sftpPermissionChecker = &DialogPermissionChecker{} } h := &host.Host{ Host: flagServer, Command: args, ForceCommand: forceCommand, Signers: signers, HostKeyCallback: hkcb, AuthorizedKeys: authorizedKeys, KeepAliveDuration: 50 * time.Second, // nlb is 350 sec & heroku router is 55 sec SessionCreatedCallback: displaySessionCallback, ClientJoinedCallback: clientJoinedCallback, ClientLeftCallback: clientLeftCallback, Stdin: os.Stdin, Stdout: os.Stdout, Logger: logger.Logger, ReadOnly: flagReadOnly, AllowLocalTCPForwarding: flagAllowLocalTCPForwarding, SFTPDisabled: flagNoSFTP, SFTPPermissionChecker: sftpPermissionChecker, } err = h.Run(c.Context()) // Handle user actions specially - no help menu var userDiscardedErr UserDiscardedError if errors.As(err, &userDiscardedErr) { return nil // Clean exit for user discard (exit code 0) } var userInterruptedErr UserInterruptedError if errors.As(err, &userInterruptedErr) { // Set both flags to prevent help menu and error display c.SilenceUsage = true c.SilenceErrors = true return userInterruptedErr } return err } func clientJoinedCallback(c *api.Client) { _ = beeep.Notify("Upterm Client Joined", notifyBody(c), icon.Upterm) } func clientLeftCallback(c *api.Client) { _ = beeep.Notify("Upterm Client Left", notifyBody(c), icon.Upterm) } func notifyBody(c *api.Client) string { return clientDesc(c.Addr, c.Version, c.PublicKeyFingerprint) } func displaySessionCallback(ctx context.Context, session *api.GetSessionResponse) error { // Build session detail (includes SCP commands if SFTP is enabled) detail, err := buildSessionDetail(session) if err != nil { return fmt.Errorf("failed to build session detail: %w", err) } // With --accept, just print session info and continue (no interactive confirmation needed) if flagAccept { tui.PrintSessionDetail(detail) return nil } // Run interactive TUI for confirmation (TTY is guaranteed by early check in shareRunE) model := tui.NewHostSessionModel(detail, false) p := tea.NewProgram(model, tea.WithContext(ctx)) finalModel, err := p.Run() if err != nil { return fmt.Errorf("session confirmation failed: %w", err) } // Extract result from the model sessionModel, ok := finalModel.(tui.HostSessionModel) if !ok { return fmt.Errorf("unexpected model type: got %T, want tui.HostSessionModel", finalModel) } // Handle the result switch sessionModel.Result() { case tui.HostSessionConfirmAccepted: return nil case tui.HostSessionConfirmRejected: return UserDiscardedError{} case tui.HostSessionConfirmInterrupted: return UserInterruptedError{} default: return fmt.Errorf("unknown confirmation result: %d", sessionModel.Result()) } } func defaultPrivateKeys(homeDir string) []string { var pks []string for _, f := range []string{ "id_ed25519", "id_ed25519_sk", "id_ecdsa", "id_ecdsa_sk", "id_dsa", "id_rsa", } { pk := filepath.Join(homeDir, ".ssh", f) if _, err := os.Stat(pk); os.IsNotExist(err) { continue } pks = append(pks, pk) } return pks } func defaultKnownHost(homeDir string) string { return filepath.Join(homeDir, ".ssh", "known_hosts") } ================================================ FILE: cmd/upterm/command/host_test.go ================================================ package command import ( "testing" "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" ) func Test_validateShareRequiredFlags_readOnlyAndLocalTCPForwarding(t *testing.T) { origServer := flagServer origReadOnly := flagReadOnly origAllowLocalTCPForwarding := flagAllowLocalTCPForwarding t.Cleanup(func() { flagServer = origServer flagReadOnly = origReadOnly flagAllowLocalTCPForwarding = origAllowLocalTCPForwarding }) flagServer = "ssh://uptermd.upterm.dev:22" cases := []struct { name string readOnly bool allowLocalTCPForwarding bool wantErrSubstr string }{ {name: "neither", readOnly: false, allowLocalTCPForwarding: false}, {name: "read-only only", readOnly: true, allowLocalTCPForwarding: false}, {name: "forwarding only", readOnly: false, allowLocalTCPForwarding: true}, { name: "both rejected", readOnly: true, allowLocalTCPForwarding: true, wantErrSubstr: "--read-only and --allow-local-tcp-forwarding cannot be used together", }, } for _, c := range cases { cc := c t.Run(cc.name, func(t *testing.T) { flagReadOnly = cc.readOnly flagAllowLocalTCPForwarding = cc.allowLocalTCPForwarding err := validateShareRequiredFlags(nil, nil) if cc.wantErrSubstr == "" { assert.NoError(t, err) return } assert.ErrorContains(t, err, cc.wantErrSubstr) }) } } func Test_parseURL(t *testing.T) { cases := []struct { name string url string wantScheme string wantHost string wantPort string }{ { name: "port 443", url: "wss://foo.com:443", wantScheme: "wss", wantHost: "foo.com", wantPort: "443", }, { name: "port 80", url: "http://foo.com:80", wantScheme: "http", wantHost: "foo.com", wantPort: "80", }, { name: "port 22", url: "ssh://foo.com:22", wantScheme: "ssh", wantHost: "foo.com", wantPort: "22", }, { name: "no port", url: "wss://foo.com", wantScheme: "wss", wantHost: "foo.com", wantPort: "443", }, } for _, c := range cases { cc := c t.Run(cc.name, func(t *testing.T) { t.Parallel() _, scheme, host, port, err := parseURL(cc.url) if err != nil { t.Fatal(err) } if diff := cmp.Diff(cc.wantScheme, scheme); diff != "" { t.Fatal(diff) } if diff := cmp.Diff(cc.wantHost, host); diff != "" { t.Fatal(diff) } if diff := cmp.Diff(cc.wantPort, port); diff != "" { t.Fatal(diff) } }) } } ================================================ FILE: cmd/upterm/command/host_unix.go ================================================ //go:build !windows package command import ( "os" ) // getDefaultShell returns the default shell on Unix systems func getDefaultShell() string { shell := os.Getenv("SHELL") if shell == "" { shell = "/bin/sh" } return shell } ================================================ FILE: cmd/upterm/command/host_windows.go ================================================ //go:build windows package command import ( "os/exec" ) // getDefaultShell returns the default shell on Windows // Prefers PowerShell Core (pwsh) if available, otherwise falls back to cmd.exe func getDefaultShell() string { // Check for PowerShell Core first if _, err := exec.LookPath("pwsh"); err == nil { // -NoLogo suppresses the copyright banner return "pwsh -NoLogo" } // Check for PowerShell if _, err := exec.LookPath("powershell"); err == nil { // -NoLogo suppresses the copyright banner return "powershell -NoLogo" } // Fallback to cmd.exe (always available on Windows) return "cmd.exe" } ================================================ FILE: cmd/upterm/command/internal/tui/host_session.go ================================================ package tui import ( "strings" tea "github.com/charmbracelet/bubbletea" ) // HostSessionConfirmResult represents the outcome of a confirmation prompt type HostSessionConfirmResult int const ( // HostSessionConfirmAccepted indicates the user accepted (pressed 'y') HostSessionConfirmAccepted HostSessionConfirmResult = iota // HostSessionConfirmRejected indicates the user rejected (pressed 'n') HostSessionConfirmRejected // HostSessionConfirmInterrupted indicates the user interrupted (pressed Ctrl+C) HostSessionConfirmInterrupted ) // HostSessionModel handles both session display and confirmation for the host command. // It renders the session information and waits for user confirmation (y/n/Ctrl+C) // unless auto-accept is enabled. type HostSessionModel struct { detail SessionDetail autoAccept bool state sessionState result HostSessionConfirmResult width int } // sessionState represents the current state of the host session prompt type sessionState int const ( // stateWaitingForConfirm indicates we're displaying the prompt and waiting for user input stateWaitingForConfirm sessionState = iota // stateDone indicates a decision has been made and we're ready to quit stateDone ) // NewHostSessionModel creates a model for displaying session and getting confirmation func NewHostSessionModel(detail SessionDetail, autoAccept bool) HostSessionModel { initialState := stateWaitingForConfirm if autoAccept { initialState = stateDone } return HostSessionModel{ detail: detail, autoAccept: autoAccept, state: initialState, result: HostSessionConfirmAccepted, // default for auto-accept width: getTermWidth(), } } func (m HostSessionModel) Init() tea.Cmd { // Auto-quit immediately if auto-accept is enabled if m.autoAccept { return tea.Quit } return nil } func (m HostSessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width case tea.KeyMsg: // Only handle input when waiting for confirmation if m.state != stateWaitingForConfirm { return m, nil } switch msg.String() { case "y", "Y": m.result = HostSessionConfirmAccepted m.state = stateDone return m, tea.Quit case "n", "N": m.result = HostSessionConfirmRejected m.state = stateDone return m, tea.Quit case "ctrl+c": m.result = HostSessionConfirmInterrupted m.state = stateDone return m, tea.Quit } } return m, nil } func (m HostSessionModel) View() string { var b strings.Builder // Session info b.WriteString(renderSessionDetail(m.detail, m.width)) if !IsTTY() { return b.String() } switch m.state { case stateWaitingForConfirm: b.WriteString("\n") b.WriteString(FooterStyle.Render("Accept connections? [y/n] (or to force exit)")) b.WriteString("\n") case stateDone: b.WriteString("\n") switch m.result { case HostSessionConfirmAccepted: b.WriteString(CommandStyle.Render("Starting to accept connections...")) b.WriteString("\n\n") b.WriteString(FooterStyle.Render("💡 Run 'upterm session current' to display session info")) case HostSessionConfirmRejected: b.WriteString(FooterStyle.Render("Session discarded.")) case HostSessionConfirmInterrupted: b.WriteString(FooterStyle.Render("Cancelled by user.")) } b.WriteString("\n") } return b.String() } // Result returns the confirmation result func (m HostSessionModel) Result() HostSessionConfirmResult { return m.result } ================================================ FILE: cmd/upterm/command/internal/tui/session_detail.go ================================================ package tui import ( "fmt" "os" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/wrap" "golang.org/x/term" ) // IsTTY returns whether stdout is a terminal func IsTTY() bool { return term.IsTerminal(int(os.Stdout.Fd())) } // getTermWidth returns the terminal width, defaulting to 80 if unavailable func getTermWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil { return 80 } return width } // RunModel runs a bubbletea model with automatic TTY detection. // For non-TTY environments, just prints View() once and returns. func RunModel(model tea.Model) (tea.Model, error) { if !IsTTY() { // Non-TTY: print View() once (lipgloss auto-strips colors) fmt.Print(model.View()) return model, nil } p := tea.NewProgram(model, tea.WithAltScreen()) return p.Run() } // SessionDetail holds session information for display type SessionDetail struct { IsCurrent bool AdminSocket string SessionID string Command string ForceCommand string Host string SSHCommand string SFTPEnabled bool // Whether SFTP/SCP is enabled SFTPCommand string // SFTP command SCPUpload string // SCP upload example SCPDownload string // SCP download example AuthorizedKeys string ConnectedClients []string } // FormatSessionDetail renders a SessionDetail to a string using terminal width func FormatSessionDetail(detail SessionDetail) string { return renderSessionDetail(detail, getTermWidth()) } // PrintSessionDetail prints session detail to stdout func PrintSessionDetail(detail SessionDetail) { fmt.Print(FormatSessionDetail(detail)) } // wrapLines wraps text to width and returns lines. // For non-TTY output, skips wrapping since output may be piped to other tools, // but still respects embedded newlines for proper layout. func wrapLines(text string, width int) []string { if text == "" { return []string{} } if !IsTTY() { return strings.Split(text, "\n") // No wrapping, but respect newlines } wrapped := wrap.String(text, max(width, 10)) return strings.Split(wrapped, "\n") } // renderWrappedRow renders a label: value row with wrapping, continuation lines indented func renderWrappedRow(b *strings.Builder, label string, value string, labelWidth int, valueWidth int, style lipgloss.Style) { l := LabelStyle.Width(labelWidth).Render(label) lines := wrapLines(value, valueWidth) if len(lines) == 0 { b.WriteString(l + "\n") return } for i, line := range lines { if i == 0 { b.WriteString(l + style.Render(line) + "\n") } else { b.WriteString(strings.Repeat(" ", labelWidth) + style.Render(line) + "\n") } } } // renderSessionDetail generates the session detail content for the given width func renderSessionDetail(detail SessionDetail, width int) string { var b strings.Builder // Title b.WriteString(TitleStyle.Render(fmt.Sprintf("Session: %s", detail.SessionID))) b.WriteString("\n\n") // Layout constants labelWidth := 18 valueWidth := max(width-labelWidth-2, 20) // Basic fields (skip empty fields to reduce noise) renderWrappedRow(&b, "Command:", detail.Command, labelWidth, valueWidth, ValueStyle) if detail.ForceCommand != "" { renderWrappedRow(&b, "Force Command:", detail.ForceCommand, labelWidth, valueWidth, ValueStyle) } renderWrappedRow(&b, "Host:", detail.Host, labelWidth, valueWidth, ValueStyle) if detail.AuthorizedKeys != "" { renderWrappedRow(&b, "Authorized Keys:", detail.AuthorizedKeys, labelWidth, valueWidth, ValueStyle) } // Commands section - each command on its own line for readability // Use wrapping to prevent truncation on narrow terminals cmdIndent := 4 cmdWidth := max(width-cmdIndent-2, 20) b.WriteString("\n") b.WriteString(LabelStyle.Render("➤ SSH:") + "\n") for _, line := range wrapLines(detail.SSHCommand, cmdWidth) { b.WriteString(strings.Repeat(" ", cmdIndent) + CommandStyle.Render(line) + "\n") } // SFTP and SCP commands (only shown if SFTP is enabled) if detail.SFTPEnabled { b.WriteString(LabelStyle.Render("➤ SFTP:") + "\n") for _, line := range wrapLines(detail.SFTPCommand, cmdWidth) { b.WriteString(strings.Repeat(" ", cmdIndent) + CommandStyle.Render(line) + "\n") } b.WriteString(LabelStyle.Render("➤ SCP:") + "\n") for _, line := range wrapLines(detail.SCPUpload, cmdWidth) { b.WriteString(strings.Repeat(" ", cmdIndent) + CommandStyle.Render(line) + "\n") } for _, line := range wrapLines(detail.SCPDownload, cmdWidth) { b.WriteString(strings.Repeat(" ", cmdIndent) + CommandStyle.Render(line) + "\n") } } // Connected clients if len(detail.ConnectedClients) > 0 { b.WriteString("\n") b.WriteString(LabelStyle.Render("Connected Clients:") + "\n") for _, client := range detail.ConnectedClients { for i, line := range wrapLines(client, width-4) { indent := 2 if i > 0 { indent = 4 } b.WriteString(strings.Repeat(" ", indent) + ValueStyle.Render(line) + "\n") } } } return b.String() } ================================================ FILE: cmd/upterm/command/internal/tui/session_detail_test.go ================================================ package tui import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_wrapLines(t *testing.T) { cases := []struct { name string text string width int want []string }{ { name: "empty string", text: "", width: 80, want: []string{}, }, { name: "single line", text: "hello world", width: 80, want: []string{"hello world"}, }, { name: "multi-line with embedded newlines", text: "owenthereal:\n- SHA256:abc123\n- SHA256:def456", width: 80, want: []string{"owenthereal:", "- SHA256:abc123", "- SHA256:def456"}, }, { name: "trailing newline", text: "line1\nline2\n", width: 80, want: []string{"line1", "line2", ""}, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { // wrapLines behavior depends on IsTTY(), but in test environment // it should be non-TTY, so we test the non-TTY path got := wrapLines(c.text, c.width) assert.Equal(t, c.want, got) }) } } func Test_renderWrappedRow_multiline(t *testing.T) { // Test that multi-line values have continuation lines properly indented var b strings.Builder labelWidth := 18 valueWidth := 60 value := "owenthereal:\n- SHA256:abc123\n- SHA256:def456" renderWrappedRow(&b, "Authorized Keys:", value, labelWidth, valueWidth, ValueStyle) got := b.String() // Check that continuation lines are indented lines := strings.Split(got, "\n") require.GreaterOrEqual(t, len(lines), 3, "expected at least 3 lines, got: %q", got) // First line should have the label assert.True(t, strings.HasPrefix(lines[0], "Authorized Keys:"), "first line should start with label, got: %q", lines[0]) // Continuation lines should be indented (start with spaces) indent := strings.Repeat(" ", labelWidth) for i := 1; i < len(lines)-1; i++ { // -1 to skip trailing empty line assert.True(t, strings.HasPrefix(lines[i], indent), "line %d should be indented with %d spaces, got: %q", i, labelWidth, lines[i]) } } func Test_FormatSessionDetail_authorizedKeys(t *testing.T) { detail := SessionDetail{ SessionID: "test123", Command: "bash", Host: "ssh://example.com:22", SSHCommand: "ssh test123@example.com", AuthorizedKeys: "user1:\n- SHA256:key1\nuser2:\n- SHA256:key2", } output := FormatSessionDetail(detail) // Verify the output contains properly formatted authorized keys assert.Contains(t, output, "Authorized Keys:") // Check that key fingerprints are indented (appear after spaces) lines := strings.Split(output, "\n") foundIndentedKey := false for _, line := range lines { // Look for lines that start with spaces followed by "- SHA256:" trimmed := strings.TrimLeft(line, " ") if strings.HasPrefix(trimmed, "- SHA256:") && strings.HasPrefix(line, " ") { foundIndentedKey = true break } } assert.True(t, foundIndentedKey, "key fingerprints should be indented in output") } ================================================ FILE: cmd/upterm/command/internal/tui/session_list.go ================================================ package tui import ( "fmt" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // SessionListModel provides an interactive session list using bubbles/table type SessionListModel struct { table table.Model sessions []SessionDetail detailView *SessionDetail // nil when showing list, non-nil when showing detail quitting bool width int } // List-specific styles (extend base styles) var ( listHeaderStyle = TitleStyle.MarginBottom(1) listFooterStyle = FooterStyle.MarginTop(1) ) // calculateColumns returns table columns sized for the given terminal width func calculateColumns(width int) []table.Column { // Fixed column const markerWidth = 2 // Table adds ~3 chars padding per column (borders + spacing) const columnPadding = 12 // 4 columns * 3 available := width - markerWidth - columnPadding if available <= 0 { available = 40 // fallback minimum } // Proportional distribution: sessionID 35%, command 25%, host 40% sessionIDWidth := max(available*35/100, 10) commandWidth := max(available*25/100, 8) hostWidth := max(available-sessionIDWidth-commandWidth, 15) return []table.Column{ {Title: "", Width: markerWidth}, {Title: "SESSION ID", Width: sessionIDWidth}, {Title: "COMMAND", Width: commandWidth}, {Title: "HOST", Width: hostWidth}, } } // NewSessionListModel creates a new interactive session list func NewSessionListModel(sessions []SessionDetail) SessionListModel { width := getTermWidth() columns := calculateColumns(width) rows := make([]table.Row, len(sessions)) cursorIdx := 0 for i, s := range sessions { marker := "" if s.IsCurrent { marker = "*" cursorIdx = i } rows[i] = table.Row{marker, s.SessionID, s.Command, s.Host} } t := table.New( table.WithColumns(columns), table.WithRows(rows), table.WithFocused(true), table.WithHeight(min(len(sessions)+1, 10)), ) s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")). BorderBottom(true). Bold(false) s.Selected = s.Selected. Foreground(lipgloss.Color("229")). Background(lipgloss.Color("236")). Bold(true) t.SetStyles(s) // Set cursor to current session t.SetCursor(cursorIdx) return SessionListModel{ table: t, sessions: sessions, width: width, } } func (m SessionListModel) Init() tea.Cmd { return nil } func (m SessionListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If showing detail view, delegate to it switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width if m.detailView == nil { m.table.SetColumns(calculateColumns(msg.Width)) } case tea.KeyMsg: // Handle detail view keys if m.detailView != nil { switch msg.String() { case "q", "esc", "enter", " ": m.detailView = nil return m, tea.ClearScreen case "ctrl+c": m.quitting = true return m, tea.Quit } return m, nil } // Handle list view keys switch msg.String() { case "q", "ctrl+c", "esc": m.quitting = true return m, tea.Quit case "enter": cursor := m.table.Cursor() if cursor >= 0 && cursor < len(m.sessions) { selected := m.sessions[cursor] m.detailView = &selected return m, tea.ClearScreen } } } var cmd tea.Cmd m.table, cmd = m.table.Update(msg) return m, cmd } func (m SessionListModel) View() string { if m.quitting { return "" } // Show detail view if active if m.detailView != nil { content := renderSessionDetail(*m.detailView, m.width) footer := FooterStyle.Render("Press q or enter to go back") return content + "\n" + footer } if len(m.sessions) == 0 { header := listHeaderStyle.Render("Active Sessions (0)") empty := EmptyStyle.Render(" No active sessions found") if !IsTTY() { return fmt.Sprintf("%s\n%s\n", header, empty) } hint := listFooterStyle.Render(" Run 'upterm host' to share your terminal") return fmt.Sprintf("%s\n%s\n\n%s\n", header, empty, hint) } header := listHeaderStyle.Render(fmt.Sprintf("Active Sessions (%d)", len(m.sessions))) if !IsTTY() { return fmt.Sprintf("%s\n%s\n", header, m.table.View()) } footer := listFooterStyle.Render("↑/↓: navigate • enter: view details • q: quit") return fmt.Sprintf("%s\n%s\n%s\n", header, m.table.View(), footer) } ================================================ FILE: cmd/upterm/command/internal/tui/styles.go ================================================ package tui import ( "os" "github.com/charmbracelet/lipgloss" ) // renderer is bound to stdout for consistent style rendering var renderer = lipgloss.NewRenderer(os.Stdout) // Common styles used across TUI components // Using basic ANSI colors (0-15) which adapt to terminal themes, // ensuring readability on both light and dark backgrounds. var ( // Title/Header style - bright cyan, bold TitleStyle = renderer.NewStyle(). Bold(true). Foreground(lipgloss.Color("14")) // Label style - white (terminal's default light color) LabelStyle = renderer.NewStyle(). Foreground(lipgloss.Color("7")) // Value style - bright white ValueStyle = renderer.NewStyle(). Foreground(lipgloss.Color("15")) // Command style - bright green, bold (for SSH commands) CommandStyle = renderer.NewStyle(). Foreground(lipgloss.Color("10")). Bold(true) // Footer style - dark gray FooterStyle = renderer.NewStyle(). Foreground(lipgloss.Color("8")) // Empty/placeholder style - dark gray, italic EmptyStyle = renderer.NewStyle(). Foreground(lipgloss.Color("8")). Italic(true) ) ================================================ FILE: cmd/upterm/command/privacy.go ================================================ package command import "os" var ( flagHideClientIP bool ) // shouldHideClientIP determines if client IP addresses should be hidden from display. // // This function checks conditions in this priority order: // 1. Explicit --hide-client-ip flag (overrides everything) // 2. UPTERM_HIDE_CLIENT_IP environment variable (automatically bound by viper) // 3. Auto-detect CI environment (if neither flag nor env var set) // // This is particularly useful for CI/CD pipelines where session output is logged // and potentially publicly visible. By default, IPs are automatically hidden in // detected CI environments to prevent accidental exposure in build logs. // // Usage: // upterm host --hide-client-ip # Explicit flag // UPTERM_HIDE_CLIENT_IP=true upterm host # Environment variable (auto-bound) // upterm host # Auto-detects CI (GitHub Actions, etc.) func shouldHideClientIP() bool { // If flag is set (either via CLI flag or via UPTERM_HIDE_CLIENT_IP env var bound by viper) if flagHideClientIP { return true } // Auto-detect CI environments as fallback return isCI() } // isCI detects if the current process is running in a CI/CD environment // by checking for common CI environment variables. func isCI() bool { ciEnvVars := []string{ "CI", // Generic CI indicator (GitHub Actions, GitLab CI, etc.) "GITHUB_ACTIONS", // GitHub Actions "GITLAB_CI", // GitLab CI "CIRCLECI", // CircleCI "TRAVIS", // Travis CI "JENKINS_URL", // Jenkins "BUILDKITE", // Buildkite "TF_BUILD", // Azure Pipelines "TEAMCITY_VERSION", // TeamCity "BITBUCKET_BUILD_NUMBER", // Bitbucket Pipelines } for _, envVar := range ciEnvVars { if os.Getenv(envVar) != "" { return true } } return false } ================================================ FILE: cmd/upterm/command/proxy.go ================================================ package command import ( "context" "fmt" "io" "net/url" "os" "github.com/oklog/run" uio "github.com/owenthereal/upterm/io" "github.com/owenthereal/upterm/ws" "github.com/spf13/cobra" ) func proxyCmd() *cobra.Command { cmd := &cobra.Command{ Use: "proxy", Short: "Proxy a terminal session via WebSocket", Long: "Proxy a terminal session via WebSocket, to be used alongside SSH ProxyCommand.", Example: ` # Host shares a session running $SHELL over WebSocket: upterm host --server wss://uptermd.upterm.dev -- YOUR_COMMAND # Client connects to the host session via WebSocket: ssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN:uptermd.uptermd.dev:443`, RunE: proxyRunE, } return cmd } func proxyRunE(c *cobra.Command, args []string) error { if len(args) == 0 { return fmt.Errorf("missing WebSocket url") } u, err := url.Parse(args[0]) if err != nil { return err } conn, err := ws.NewWSConn(u, true) if err != nil { return err } ctx, cancel := context.WithCancel(context.Background()) defer cancel() var g run.Group { g.Add(func() error { _, err := io.Copy(conn, uio.NewContextReader(ctx, os.Stdin)) return err }, func(err error) { _ = conn.Close() cancel() }) } { g.Add(func() error { _, err := io.Copy(os.Stdout, uio.NewContextReader(ctx, conn)) return err }, func(err error) { _ = conn.Close() cancel() }) } return g.Run() } ================================================ FILE: cmd/upterm/command/root.go ================================================ package command import ( "fmt" "os" "strings" uptermctx "github.com/owenthereal/upterm/internal/context" "github.com/owenthereal/upterm/internal/logging" "github.com/owenthereal/upterm/utils" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" ) func Root() *cobra.Command { rootCmd := &cobra.Command{ Use: "upterm", Short: "Instant Terminal Sharing", Long: `Upterm is an open-source solution for sharing terminal sessions instantly over secure SSH tunnels to the public internet. Configuration Priority (highest to lowest): 1. Command-line flags 2. Environment variables (UPTERM_ prefix) 3. Config file (see below) 4. Default values Config File: ~/.config/upterm/config.yaml (Linux) ~/Library/Application Support/upterm/config.yaml (macOS) %LOCALAPPDATA%\upterm\config.yaml (Windows) Run 'upterm config path' to see your config file location. Run 'upterm config edit' to create and edit the config file. Environment Variables: All flags can be set via environment variables with the UPTERM_ prefix. Flag names are converted by replacing hyphens (-) with underscores (_). Examples: --hide-client-ip → UPTERM_HIDE_CLIENT_IP=true --read-only → UPTERM_READ_ONLY=true --accept → UPTERM_ACCEPT=true`, Example: ` # Host a terminal session running $SHELL, attaching client's IO to the host's: $ upterm host # Display the SSH connection string for sharing with client(s): $ upterm session current === SESSION_ID Command: /bin/bash Force Command: n/a Host: ssh://uptermd.upterm.dev:22 SSH Session: ssh TOKEN@uptermd.upterm.dev # A client connects to the host session via SSH: $ ssh TOKEN@uptermd.upterm.dev # Set flags via environment variables: $ UPTERM_HIDE_CLIENT_IP=true upterm host`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Bind all flags to environment variables with UPTERM_ prefix if err := bindFlagsToEnv(cmd); err != nil { return err } debug, _ := cmd.Flags().GetBool("debug") logOptions := []logging.Option{logging.File(utils.UptermLogFilePath())} if debug { logOptions = append(logOptions, logging.Debug()) } logger, err := logging.New(logOptions...) if err != nil { return err } cmd.SetContext(uptermctx.WithLogger(cmd.Context(), logger)) return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { if logger := uptermctx.Logger(cmd.Context()); logger != nil { return logger.Close() } return nil }, } logPath := utils.UptermLogFilePath() rootCmd.PersistentFlags().Bool("debug", os.Getenv("DEBUG") != "", fmt.Sprintf("enable debug level logging (log file: %s).", logPath)) rootCmd.AddCommand(configCmd()) rootCmd.AddCommand(hostCmd()) rootCmd.AddCommand(proxyCmd()) rootCmd.AddCommand(sessionCmd()) rootCmd.AddCommand(upgradeCmd()) rootCmd.AddCommand(versionCmd()) return rootCmd } // bindFlagsToEnv binds all command flags to config file and environment variables. // Configuration priority (highest to lowest): // 1. Command-line flags // 2. Environment variables with UPTERM_ prefix // 3. Config file (XDG_CONFIG_HOME/upterm/config.yaml) // 4. Default values // // Examples: // // --hide-client-ip flag -> UPTERM_HIDE_CLIENT_IP env var -> hide-client-ip in config.yaml // --read-only flag -> UPTERM_READ_ONLY env var -> read-only in config.yaml func bindFlagsToEnv(cmd *cobra.Command) error { v := viper.New() // Configure config file configPath := utils.UptermConfigFilePath() v.SetConfigFile(configPath) // Try to read config file (silent fail if not exists, but warn on parse errors) if err := v.ReadInConfig(); err != nil { // Only warn if the file exists but can't be parsed if _, statErr := os.Stat(configPath); statErr == nil { // File exists but couldn't be read - log warning if we have logger if logger := uptermctx.Logger(cmd.Context()); logger != nil { logger.Warn("Failed to read config file", "path", configPath, "error", err) } } // Otherwise silently continue - config file is optional } // Visit all flags and bind them to viper cmd.Flags().VisitAll(func(flag *pflag.Flag) { if flag.Name != "help" { // Ignore binding errors - not all flags support environment variable binding _ = v.BindPFlag(flag.Name, flag) } }) // Enable automatic environment variable reading v.AutomaticEnv() // Replace hyphens with underscores for env var names (--hide-client-ip -> HIDE_CLIENT_IP) v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) // Set prefix so all env vars start with UPTERM_ (UPTERM_HIDE_CLIENT_IP) v.SetEnvPrefix("UPTERM") // Sync viper values back to flags // Priority: flags (if changed) > env vars > config file > defaults cmd.Flags().VisitAll(func(flag *pflag.Flag) { if flag.Name != "help" && !flag.Changed && v.IsSet(flag.Name) { val := v.Get(flag.Name) // Ignore setting errors - not all flag types can be set from strings _ = cmd.Flags().Set(flag.Name, toString(val)) } }) return nil } // toString converts a value to string for flag setting. // Handles bool and string slice types specially, uses fmt.Sprintf for others. func toString(val any) string { switch v := val.(type) { case bool: if v { return "true" } return "false" case string: return v case []string: // For string slice flags (e.g., --private-key), join with commas return strings.Join(v, ",") default: // For all other types (int, float, etc.), use fmt.Sprintf return fmt.Sprintf("%v", v) } } ================================================ FILE: cmd/upterm/command/session.go ================================================ package command import ( "context" "encoding/json" "fmt" "net" "net/url" "os" "path/filepath" "strings" "text/template" "github.com/owenthereal/upterm/cmd/upterm/command/internal/tui" "github.com/owenthereal/upterm/host" "github.com/owenthereal/upterm/host/api" "github.com/owenthereal/upterm/routing" "github.com/owenthereal/upterm/upterm" "github.com/owenthereal/upterm/utils" "github.com/spf13/cobra" ) var ( flagAdminSocket string flagOutput string ) // sessionTemplateData holds data for template output type sessionTemplateData struct { SessionID string `json:"sessionId"` ClientCount int `json:"clientCount"` Host string `json:"host"` Command string `json:"command"` ForceCommand string `json:"forceCommand"` } func sessionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "session", Aliases: []string{"se"}, Short: "Display and manage terminal sessions", } cmd.AddCommand(current()) cmd.AddCommand(list()) cmd.AddCommand(show()) return cmd } func list() *cobra.Command { runtimeDir := utils.UptermRuntimeDir() cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls", "l"}, Short: "List shared sessions", Long: fmt.Sprintf(`List shared sessions. Sockets are stored in: %s Follows the XDG Base Directory Specification with fallback to $HOME/.upterm in constrained environments where XDG directories are unavailable.`, runtimeDir), Example: ` # List shared sessions: upterm session list`, RunE: listRunE, } return cmd } func show() *cobra.Command { cmd := &cobra.Command{ Use: "info", Aliases: []string{"i"}, Short: "Display terminal session by name", Long: `Display terminal session by name.`, Example: ` # Display session by name: upterm session info NAME`, RunE: infoRunE, } cmd.Flags().BoolVar(&flagHideClientIP, "hide-client-ip", false, "Hide client IP addresses from output (auto-enabled in CI environments).") return cmd } func current() *cobra.Command { runtimeDir := utils.UptermRuntimeDir() cmd := &cobra.Command{ Use: "current", Aliases: []string{"c"}, Short: "Display the current terminal session", Long: fmt.Sprintf(`Display the current terminal session. By default, reads the admin socket path from $UPTERM_ADMIN_SOCKET (automatically set when you run 'upterm host'). Sockets are stored in: %s Follows the XDG Base Directory Specification with fallback to $HOME/.upterm in constrained environments where XDG directories are unavailable. Output formats: -o json JSON output -o go-template='{{.ClientCount}}' Custom Go template Template variables: SessionID, ClientCount, Host, Command, ForceCommand`, runtimeDir), Example: ` # Display the active session as defined in $UPTERM_ADMIN_SOCKET: upterm session current # Output as JSON: upterm session current -o json # Custom format for shell prompt (outputs nothing if not in session): upterm session current -o go-template='🆙 {{.ClientCount}} ' # For terminal title: upterm session current -o go-template='upterm: {{.ClientCount}} clients | {{.SessionID}}'`, PreRunE: validateCurrentRequiredFlags, RunE: currentRunE, } cmd.PersistentFlags().StringVarP(&flagAdminSocket, "admin-socket", "", currentAdminSocketFile(), "Admin socket path (required).") cmd.Flags().StringVarP(&flagOutput, "output", "o", "", "Output format: json or go-template='...'") cmd.Flags().BoolVar(&flagHideClientIP, "hide-client-ip", false, "Hide client IP addresses from output (auto-enabled in CI environments).") return cmd } func listRunE(c *cobra.Command, args []string) error { sessions, err := listSessions(c.Context(), utils.UptermRuntimeDir()) if err != nil { return err } model := tui.NewSessionListModel(sessions) _, err = tui.RunModel(model) return err } // fetchSessionDetail returns session details for an admin socket func fetchSessionDetail(ctx context.Context, adminSocket string) (tui.SessionDetail, error) { sess, err := session(ctx, adminSocket) if err != nil { return tui.SessionDetail{}, err } return buildSessionDetail(sess) } func infoRunE(c *cobra.Command, args []string) error { if len(args) == 0 { return fmt.Errorf("missing session name") } adminSocket := filepath.Join(utils.UptermRuntimeDir(), host.AdminSocketFile(args[0])) detail, err := fetchSessionDetail(c.Context(), adminSocket) if err != nil { return err } tui.PrintSessionDetail(detail) return nil } func currentRunE(c *cobra.Command, args []string) error { // If output format specified, use special handling (non-interactive) if flagOutput != "" { return outputSession(c.Context(), flagAdminSocket, flagOutput) } detail, err := fetchSessionDetail(c.Context(), flagAdminSocket) if err != nil { return err } tui.PrintSessionDetail(detail) return nil } // outputSession handles -o/--output flag for session current func outputSession(ctx context.Context, adminSocket, format string) error { // Error if not in upterm session (no admin socket) if adminSocket == "" { return fmt.Errorf("not in upterm session (UPTERM_ADMIN_SOCKET not set)") } // Validate format if format != "json" && !strings.HasPrefix(format, "go-template=") { return fmt.Errorf("invalid output format %q: must be 'json' or 'go-template=