Showing preview only (712K chars total). Download the full file or copy to clipboard to get everything.
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 <o@owenou.com>
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
[](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.

## :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:
[](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=<your cert resolver here>"
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 `<your cert resolver here>` 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 <ctrl-c> 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=<template>'", format)
}
// Try to get session
sess, err := session(ctx, adminSocket)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
// Build template data
data := sessionTemplateData{
SessionID: sess.SessionId,
ClientCount: len(sess.ConnectedClients),
Host: sess.Host,
Command: strings.Join(sess.Command, " "),
ForceCommand: strings.Join(sess.ForceCommand, " "),
}
// Handle json output
if format == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(data)
}
// Handle go-template output
tmplStr := strings.TrimPrefix(format, "go-template=")
// Remove surrounding quotes if present
tmplStr = strings.Trim(tmplStr, "'\"")
tmpl, err := template.New("session").Parse(tmplStr)
if err != nil {
return fmt.Errorf("invalid template: %w", err)
}
return tmpl.Execute(os.Stdout, data)
}
func listSessions(ctx context.Context, dir string) ([]tui.SessionDetail, error) {
var result []tui.SessionDetail
files, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
currentAdminSocket := currentAdminSocketFile()
for _, file := range files {
// continue if the file is not SESSION.sock
if filepath.Ext(file.Name()) != host.AdminSockExt {
continue
}
adminSocket := filepath.Join(dir, file.Name())
sess, err := session(ctx, adminSocket)
if err != nil {
continue
}
detail, err := buildSessionDetail(sess)
if err != nil {
continue
}
detail.IsCurrent = adminSocket == currentAdminSocket
detail.AdminSocket = adminSocket
result = append(result, detail)
}
return result, nil
}
func parseURL(str string) (u *url.URL, scheme string, host string, port string, err error) {
u, err = url.Parse(str)
if err != nil {
return
}
scheme = u.Scheme
host, port, err = net.SplitHostPort(u.Host)
if err != nil {
if !strings.Contains(err.Error(), "missing port in address") {
return
}
err = nil
host = u.Host
switch u.Scheme {
case "ssh":
port = "22"
case "ws":
port = "80"
case "wss":
port = "443"
}
}
return
}
// buildSessionDetail returns session detail for TUI display
func buildSessionDetail(sess *api.GetSessionResponse) (tui.SessionDetail, error) {
user := sess.SshUser
if user == "" {
// Fallback to encoding for backward compatibility with older servers
user = routing.NewEncodeDecoder(routing.ModeEmbedded).Encode(sess.SessionId, sess.NodeAddr)
}
u, scheme, host, port, err := parseURL(sess.Host)
if err != nil {
return tui.SessionDetail{}, err
}
var hostPort string
if port == "" || port == "80" || port == "443" {
hostPort = host
} else {
hostPort = host + ":" + port
}
var sshCmd string
if scheme == "ssh" {
sshCmd = fmt.Sprintf("ssh %s@%s", user, host)
if port != "22" {
sshCmd = fmt.Sprintf("%s -p %s", sshCmd, port)
}
} else {
sshCmd = fmt.Sprintf("ssh -o ProxyCommand='upterm proxy %s://%s@%s' %s@%s", scheme, user, hostPort, user, host+":"+port)
}
var clients []string
for _, c := range sess.ConnectedClients {
clients = append(clients, clientDesc(c.Addr, c.Version, c.PublicKeyFingerprint))
}
// Build SFTP/SCP commands if enabled and using direct SSH
var sftpCmd, scpUpload, scpDownload string
sftpEnabled := !sess.SftpDisabled && scheme == "ssh"
if sftpEnabled {
// SFTP command (similar to SSH)
if port != "" && port != "22" {
sftpCmd = fmt.Sprintf("sftp -P %s %s@%s", port, user, host)
scpUpload = fmt.Sprintf("scp -P %s <local> %s@%s:<remote>", port, user, host)
scpDownload = fmt.Sprintf("scp -P %s %s@%s:<remote> <local>", port, user, host)
} else {
sftpCmd = fmt.Sprintf("sftp %s@%s", user, host)
scpUpload = fmt.Sprintf("scp <local> %s@%s:<remote>", user, host)
scpDownload = fmt.Sprintf("scp %s@%s:<remote> <local>", user, host)
}
}
return tui.SessionDetail{
SessionID: sess.SessionId,
Command: strings.Join(sess.Command, " "),
ForceCommand: strings.Join(sess.ForceCommand, " "),
Host: u.Scheme + "://" + hostPort,
SSHCommand: sshCmd,
SFTPEnabled: sftpEnabled,
SFTPCommand: sftpCmd,
SCPUpload: scpUpload,
SCPDownload: scpDownload,
AuthorizedKeys: displayAuthorizedKeys(sess.AuthorizedKeys),
ConnectedClients: clients,
}, nil
}
func clientDesc(addr, clientVer, fingerprint string) string {
if shouldHideClientIP() {
addr = "[redacted]"
}
return fmt.Sprintf("%s %s %s", addr, clientVer, fingerprint)
}
func currentAdminSocketFile() string {
return os.Getenv(upterm.HostAdminSocketEnvVar)
}
func session(ctx context.Context, adminSocket string) (*api.GetSessionResponse, error) {
c, err := host.AdminClient(adminSocket)
if err != nil {
return nil, err
}
return c.GetSession(ctx, &api.GetSessionRequest{})
}
func validateCurrentRequiredFlags(c *cobra.Command, args []string) error {
missingFlagNames := []string{}
if flagAdminSocket == "" {
missingFlagNames = append(missingFlagNames, "admin-socket")
}
if len(missingFlagNames) > 0 {
return fmt.Errorf(`required flag(s) "%s" not set`, strings.Join(missingFlagNames, ", "))
}
return nil
}
func displayAuthorizedKeys(keys []*api.AuthorizedKey) string {
var aks []string
for _, ak := range keys {
if len(ak.PublicKeyFingerprints) == 0 {
aks = append(aks, fmt.Sprintf("[!] %s (no SSH keys configured)", ak.Comment))
} else {
var fps []string
for _, fp := range ak.PublicKeyFingerprints {
fps = append(fps, fmt.Sprintf("- %s", fp))
}
aks = append(aks, fmt.Sprintf("%s:\n%s", ak.Comment, strings.Join(fps, "\n")))
}
}
return strings.Join(aks, "\n")
}
================================================
FILE: cmd/upterm/command/sftp_permission.go
================================================
package command
import (
"fmt"
"strings"
"sync"
"github.com/ncruces/zenity"
"github.com/owenthereal/upterm/host/sftp"
"github.com/owenthereal/upterm/utils"
)
// DialogPermissionChecker shows GUI dialogs for permission prompts.
type DialogPermissionChecker struct {
// allowedSessions tracks SSH sessions where user clicked "Allow All".
// All operations in these sessions are auto-allowed.
allowedSessions sync.Map // map[sessionID]struct{}
// allowedFiles tracks files where user clicked "Allow" (per session).
// All operations on these files are auto-allowed for that session.
// Key format: "sessionID:path"
allowedFiles sync.Map // map[string]struct{}
}
// CheckPermission shows a dialog for the operation.
// For two-path operations (rename, symlink, link), both source and target paths are passed.
func (d *DialogPermissionChecker) CheckPermission(op sftp.Operation, client sftp.ClientInfo, paths ...string) (sftp.PermissionResult, error) {
if len(paths) == 0 {
return sftp.PermissionDenied, fmt.Errorf("no path provided")
}
// Auto-allow if user clicked "Allow All" for this session
if d.isSessionAllowed(client.SessionID) {
return sftp.PermissionAllowed, nil
}
// Auto-allow if user clicked "Allow" for all involved paths in this session
allPathsAllowed := true
for _, p := range paths {
if !d.isFileAllowed(client.SessionID, p) {
allPathsAllowed = false
break
}
}
if allPathsAllowed {
return sftp.PermissionAllowed, nil
}
result, err := d.showDialog(op, client, paths)
// Track based on user's choice
switch result {
case sftp.PermissionAlwaysAllow:
// "Allow All" - allow all operations in this session
d.allowSession(client.SessionID)
case sftp.PermissionAllowed:
// "Allow" - allow all operations on these paths in this session
for _, p := range paths {
d.allowFile(client.SessionID, p)
}
}
return result, err
}
func (d *DialogPermissionChecker) allowSession(sessionID string) {
d.allowedSessions.Store(sessionID, struct{}{})
}
func (d *DialogPermissionChecker) isSessionAllowed(sessionID string) bool {
_, ok := d.allowedSessions.Load(sessionID)
return ok
}
func (d *DialogPermissionChecker) allowFile(sessionID, path string) {
key := sessionID + ":" + path
d.allowedFiles.Store(key, struct{}{})
}
func (d *DialogPermissionChecker) isFileAllowed(sessionID, path string) bool {
key := sessionID + ":" + path
_, ok := d.allowedFiles.Load(key)
return ok
}
// ClearSession removes cached permissions for the given session.
func (d *DialogPermissionChecker) ClearSession(sessionID string) {
// Remove session-level permission
d.allowedSessions.Delete(sessionID)
// Remove all file-level permissions for this session
prefix := sessionID + ":"
d.allowedFiles.Range(func(key, _ any) bool {
if k, ok := key.(string); ok && strings.HasPrefix(k, prefix) {
d.allowedFiles.Delete(key)
}
return true
})
}
func (d *DialogPermissionChecker) showDialog(op sftp.Operation, client sftp.ClientInfo, paths []string) (sftp.PermissionResult, error) {
title := "Upterm File Transfer"
// Format client identifier
clientID := "unknown"
if client.Fingerprint != "" {
clientID = client.Fingerprint
}
// Use shortened paths for user-friendly display (e.g., ~/foo instead of /Users/name/foo)
displayPath := utils.ShortenHomePath(paths[0])
var displayTarget string
if len(paths) > 1 {
displayTarget = utils.ShortenHomePath(paths[1])
}
var msg string
switch op {
case sftp.OpDownload:
msg = fmt.Sprintf("Client [%s] wants to download:\n%s", clientID, displayPath)
case sftp.OpUpload:
msg = fmt.Sprintf("Client [%s] wants to upload:\n%s", clientID, displayPath)
case sftp.OpDelete, sftp.OpRmdir:
msg = fmt.Sprintf("Client [%s] wants to delete:\n%s", clientID, displayPath)
case sftp.OpMkdir:
msg = fmt.Sprintf("Client [%s] wants to create directory:\n%s", clientID, displayPath)
case sftp.OpRename:
if displayTarget != "" {
msg = fmt.Sprintf("Client [%s] wants to rename:\n%s → %s", clientID, displayPath, displayTarget)
} else {
msg = fmt.Sprintf("Client [%s] wants to rename:\n%s", clientID, displayPath)
}
case sftp.OpSymlink:
if displayTarget != "" {
msg = fmt.Sprintf("Client [%s] wants to create symlink:\n%s → %s", clientID, displayPath, displayTarget)
} else {
msg = fmt.Sprintf("Client [%s] wants to create symlink:\n%s", clientID, displayPath)
}
case sftp.OpLink:
if displayTarget != "" {
msg = fmt.Sprintf("Client [%s] wants to create hard link:\n%s → %s", clientID, displayPath, displayTarget)
} else {
msg = fmt.Sprintf("Client [%s] wants to create hard link:\n%s", clientID, displayPath)
}
case sftp.OpSetstat:
msg = fmt.Sprintf("Client [%s] wants to modify file attributes:\n%s", clientID, displayPath)
default:
msg = fmt.Sprintf("Client [%s] wants to %s:\n%s", clientID, op.String(), displayPath)
}
// Show question dialog with Allow/Allow All/Deny buttons
err := zenity.Question(msg,
zenity.Title(title),
zenity.OKLabel("Allow"),
zenity.CancelLabel("Deny"),
zenity.ExtraButton("Allow All"),
)
if err == nil {
return sftp.PermissionAllowed, nil
}
if err == zenity.ErrExtraButton {
return sftp.PermissionAlwaysAllow, nil
}
if err == zenity.ErrCanceled {
return sftp.PermissionDenied, nil
}
// Other error (e.g., zenity not installed, no display)
return sftp.PermissionDenied, err
}
// AutoAllowPermissionChecker always allows operations (for --accept mode or testing).
type AutoAllowPermissionChecker struct{}
// CheckPermission always returns PermissionAllowed.
func (a *AutoAllowPermissionChecker) CheckPermission(op sftp.Operation, client sftp.ClientInfo, paths ...string) (sftp.PermissionResult, error) {
return sftp.PermissionAllowed, nil
}
// ClearSession is a no-op since AutoAllowPermissionChecker doesn't track sessions.
func (a *AutoAllowPermissionChecker) ClearSession(sessionID string) {}
================================================
FILE: cmd/upterm/command/upgrade.go
================================================
package command
import (
"context"
"fmt"
"path/filepath"
"runtime"
"strings"
"time"
ggh "github.com/google/go-github/v48/github"
uptermctx "github.com/owenthereal/upterm/internal/context"
"github.com/owenthereal/upterm/internal/version"
"github.com/spf13/cobra"
"github.com/tj/go-update"
"github.com/tj/go-update/progress"
"github.com/tj/go/term"
)
func upgradeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "upgrade",
Short: "Upgrade the CLI",
Example: ` # Upgrade to the latest version:
upterm upgrade
# Upgrade to a specific version:
upterm upgrade 0.2.0`,
RunE: upgradeRunE,
}
return cmd
}
func upgradeRunE(c *cobra.Command, args []string) error {
logger := uptermctx.Logger(c.Context())
if logger == nil {
return fmt.Errorf("logger not available")
}
term.HideCursor()
defer term.ShowCursor()
m := &update.Manager{
Command: "upterm",
Store: &store{
Owner: "owenthereal",
Repo: "upterm",
Version: version.String(),
},
}
var r release
if len(args) > 0 {
rr, err := m.GetRelease(trimVPrefix(args[0]))
if err != nil {
return fmt.Errorf("error fetching release: %s", err)
}
r = release{rr}
} else {
// fetch the new releases
releases, err := m.LatestReleases()
if err != nil {
logger.Error("error fetching releases", "error", err)
return fmt.Errorf("error fetching releases: %w", err)
}
// no updates
if len(releases) == 0 {
return fmt.Errorf("no updates")
}
// latest release
r = release{releases[0]}
}
if version.String() == trimVPrefix(r.Version) {
fmt.Println("Upterm is up-to-date")
return nil
}
// find the tarball for this system
a := r.FindTarballWithVersion(runtime.GOOS, runtime.GOARCH)
if a == nil {
return fmt.Errorf("no binary for your system")
}
// download tarball to a tmp dir
tarball, err := a.DownloadProxy(progress.Reader)
if err != nil {
return fmt.Errorf("error downloading: %s", err)
}
// install it
if err := m.Install(tarball); err != nil {
return fmt.Errorf("error installing: %s", err)
}
fmt.Printf("Upgraded upterm %s to %s\n", version.String(), trimVPrefix(r.Version))
return nil
}
func trimVPrefix(s string) string {
return strings.TrimPrefix(s, "v")
}
type release struct {
*update.Release
}
func (r *release) FindTarballWithVersion(os, arch string) *update.Asset {
s := fmt.Sprintf("%s_%s", os, arch)
for _, a := range r.Assets {
ext := filepath.Ext(a.Name)
if strings.Contains(a.Name, s) && ext == ".gz" {
return a
}
}
return nil
}
type store struct {
Owner string
Repo string
Version string
}
func (s *store) GetRelease(version string) (*update.Release, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
gh := ggh.NewClient(nil)
r, res, err := gh.Repositories.GetReleaseByTag(ctx, s.Owner, s.Repo, "v"+version)
if res.StatusCode == 404 {
return nil, update.ErrNotFound
}
if err != nil {
return nil, err
}
return githubRelease(r), nil
}
func (s *store) LatestReleases() ([]*update.Release, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
gh := ggh.NewClient(nil)
r, _, err := gh.Repositories.GetLatestRelease(ctx, s.Owner, s.Repo)
if err != nil {
return nil, err
}
return []*update.Release{
githubRelease(r),
}, nil
}
func githubRelease(r *ggh.RepositoryRelease) *update.Release {
out := &update.Release{
Version: r.GetTagName(),
Notes: r.GetBody(),
PublishedAt: r.GetPublishedAt().Time,
URL: r.GetURL(),
}
for _, a := range r.Assets {
out.Assets = append(out.Assets, &update.Asset{
Name: a.GetName(),
Size: a.GetSize(),
URL: a.GetBrowserDownloadURL(),
Downloads: a.GetDownloadCount(),
})
}
return out
}
================================================
FILE: cmd/upterm/command/version.go
================================================
package command
import (
"github.com/owenthereal/upterm/internal/version"
"github.com/spf13/cobra"
)
func versionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Show version",
RunE: func(c *cobra.Command, args []string) error {
version.PrintVersion("Upterm")
return nil
},
}
return cmd
}
================================================
FILE: cmd/upterm/main.go
================================================
package main
import (
"errors"
"log/slog"
"os"
"github.com/owenthereal/upterm/cmd/upterm/command"
)
func main() {
if err := command.Root().Execute(); err != nil {
// Don't log errors that have already been displayed to the user
var silentErr command.SilentError
if !errors.As(err, &silentErr) {
slog.Error("Error executing command", "error", err)
}
os.Exit(1)
}
}
================================================
FILE: cmd/uptermd/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/routing"
"github.com/owenthereal/upterm/server"
"github.com/owenthereal/upterm/utils"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
func Root() *cobra.Command {
rootCmd := &rootCmd{}
cmd := &cobra.Command{
Use: "uptermd",
Short: "Upterm Daemon",
RunE: rootCmd.RunE,
}
cmd.PersistentFlags().String("config", "", "server config")
cmd.PersistentFlags().StringP("ssh-addr", "", utils.DefaultLocalhost("2222"), "ssh server address")
cmd.PersistentFlags().StringP("ws-addr", "", "", "websocket server address")
cmd.PersistentFlags().StringP("node-addr", "", "", "node address")
cmd.PersistentFlags().StringSliceP("authorized-keys", "", nil, "authorized_keys file(s) controlling which public keys may register as hosts; may be repeated, mirroring OpenSSH's AuthorizedKeysFile directive")
cmd.PersistentFlags().StringSliceP("private-key", "", nil, "server private key")
cmd.PersistentFlags().StringSliceP("hostname", "", nil, "server hostname for public-key authentication certificate principals. If empty, public-key authentication is used instead.")
cmd.PersistentFlags().BoolP("ssh-proxy-protocol", "", false, "enable PROXY protocol support for the SSH listener (for use behind TCP proxies like Traefik, HAProxy, or AWS ELB)")
cmd.PersistentFlags().StringP("network", "", "mem", "network provider")
cmd.PersistentFlags().StringSliceP("network-opt", "", nil, "network provider option")
cmd.PersistentFlags().StringP("metric-addr", "", "", "metric server address")
cmd.PersistentFlags().BoolP("debug", "", os.Getenv("DEBUG") != "", "debug")
cmd.PersistentFlags().String("routing", string(routing.ModeEmbedded), "session routing mode")
cmd.PersistentFlags().String("consul-url", "", "consul URL for routing mode 'consul'")
cmd.PersistentFlags().String("consul-session-ttl", server.DefaultSessionTTL.String(), "consul session TTL for routing mode 'consul'")
cmd.PersistentFlags().String("sentry-dsn", "", "Sentry DSN for error tracking")
cmd.AddCommand(versionCmd())
return cmd
}
type rootCmd struct {
}
func (cmd *rootCmd) RunE(c *cobra.Command, args []string) error {
var opt server.Opt
if err := unmarshalFlags(c, &opt); err != nil {
return err
}
logOptions := []logging.Option{logging.Console()}
if opt.Debug {
logOptions = append(logOptions, logging.Debug())
}
if opt.SentryDSN != "" {
logOptions = append(logOptions, logging.Sentry(opt.SentryDSN))
}
logger, err := logging.New(logOptions...)
if err != nil {
return err
}
defer func() {
_ = logger.Close()
}()
c.SetContext(uptermctx.WithLogger(c.Context(), logger))
if err := server.Start(c.Context(), opt, logger.Logger); err != nil {
logger.Error("failed to start uptermd", "error", err)
return fmt.Errorf("failed to start uptermd: %w", err)
}
return nil
}
func unmarshalFlags(cmd *cobra.Command, opts interface{}) error {
v := viper.New()
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
flagName := flag.Name
if flagName != "config" && flagName != "help" {
if err := v.BindPFlag(flagName, flag); err != nil {
panic(fmt.Errorf("error binding flag '%s': %w", flagName, err).Error())
}
}
})
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
v.SetEnvPrefix("UPTERMD")
// Bind SENTRY_DSN directly (standard convention), with UPTERMD_SENTRY_DSN as fallback
_ = v.BindEnv("sentry-dsn", "SENTRY_DSN", "UPTERMD_SENTRY_DSN")
cfgFile, err := cmd.Flags().GetString("config")
if err != nil {
return err
}
if _, err := os.Stat(cfgFile); err == nil {
v.SetConfigFile(cfgFile)
}
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("error loading config file %s: %w", cfgFile, err)
}
}
return v.Unmarshal(opts)
}
================================================
FILE: cmd/uptermd/command/version.go
================================================
package command
import (
"github.com/owenthereal/upterm/internal/version"
"github.com/spf13/cobra"
)
func versionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Show version",
RunE: func(c *cobra.Command, args []string) error {
version.PrintVersion("Uptermd")
return nil
},
}
return cmd
}
================================================
FILE: cmd/uptermd/main.go
================================================
package main
import (
"log/slog"
"os"
"github.com/owenthereal/upterm/cmd/uptermd/command"
)
func main() {
if err := command.Root().Execute(); err != nil {
slog.Error("command execution failed", "error", err)
os.Exit(1)
}
}
================================================
FILE: cmd/uptermd-fly/main.go
================================================
package main
import (
"fmt"
"log/slog"
"os"
"github.com/owenthereal/upterm/cmd/uptermd/command"
)
func main() {
flyAppName := os.Getenv("FLY_APP_NAME")
if flyAppName == "" {
slog.Error("FLY_APP_NAME is not set")
os.Exit(1)
}
flyMachineID := os.Getenv("FLY_MACHINE_ID")
if flyMachineID == "" {
slog.Error("FLY_MACHINE_ID is not set")
os.Exit(1)
}
config := map[string]any{
"UPTERMD_SSH_ADDR": "[::]:2222",
"UPTERMD_WS_ADDR": "[::]:8080",
"UPTERMD_NODE_ADDR": fmt.Sprintf("%s.vm.%s.internal:2222", flyMachineID, flyAppName),
"UPTERMD_SSH_PROXY_PROTOCOL": "true",
"UPTERMD_METRIC_ADDR": "[::]:9091",
}
flyConsulURL := os.Getenv("FLY_CONSUL_URL")
if flyConsulURL != "" {
config["UPTERMD_ROUTING"] = "consul"
config["UPTERMD_CONSUL_URL"] = flyConsulURL
config["UPTERMD_CONSUL_SESSION_TTL"] = "1h"
slog.Info("Using Consul routing for multi-machine deployment")
} else {
config["UPTERMD_ROUTING"] = "embedded"
slog.Info("Using embedded routing for single-machine deployment")
}
for key, value := range config {
if err := os.Setenv(key, fmt.Sprintf("%v", value)); err != nil {
slog.Error("failed to set environment variable", "key", key, "error", err)
os.Exit(1)
}
}
slog.Info("Starting uptermd on Fly.io", "config", config)
if err := command.Root().Execute(); err != nil {
slog.Error("command execution failed", "error", err)
os.Exit(1)
}
}
================================================
FILE: docs/upterm.md
================================================
## upterm
Instant Terminal Sharing
### Synopsis
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
### Examples
```
# 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
```
### Options
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
-h, --help help for upterm
```
### SEE ALSO
* [upterm config](upterm_config.md) - Manage upterm configuration
* [upterm host](upterm_host.md) - Host a terminal session
* [upterm proxy](upterm_proxy.md) - Proxy a terminal session via WebSocket
* [upterm session](upterm_session.md) - Display and manage terminal sessions
* [upterm upgrade](upterm_upgrade.md) - Upgrade the CLI
* [upterm version](upterm_version.md) - Show version
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_config.md
================================================
## upterm config
Manage upterm configuration
### Synopsis
Manage upterm configuration file.
Config file: /home/user/.config/upterm/config.yaml
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
### Options
```
-h, --help help for config
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm](upterm.md) - Instant Terminal Sharing
* [upterm config edit](upterm_config_edit.md) - Edit the config file
* [upterm config path](upterm_config_path.md) - Show the path to the config file
* [upterm config view](upterm_config_view.md) - View the config file contents
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_config_edit.md
================================================
## upterm config edit
Edit the config file
### Synopsis
Edit the config file in your default editor.
Config file: /home/user/.config/upterm/config.yaml
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.
```
upterm config edit [flags]
```
### Examples
```
# Edit config file:
upterm config edit
# Use a specific editor:
EDITOR=nano upterm config edit
```
### Options
```
-h, --help help for edit
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm config](upterm_config.md) - Manage upterm configuration
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_config_path.md
================================================
## upterm config path
Show the path to the config file
### Synopsis
Show the path to the config file.
Config file: /home/user/.config/upterm/config.yaml
The config file is optional and created manually by users.
```
upterm config path [flags]
```
### Examples
```
# Show config file path:
upterm config path
# Create config file directory:
mkdir -p "$(dirname "$(upterm config path)")"
```
### Options
```
-h, --help help for path
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm config](upterm_config.md) - Manage upterm configuration
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_config_view.md
================================================
## upterm config view
View the config file contents
### Synopsis
View the config file contents.
Config file: /home/user/.config/upterm/config.yaml
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.
```
upterm config view [flags]
```
### Examples
```
# View current config:
upterm config view
# View and save as new config:
upterm config view > "$(upterm config path)"
```
### Options
```
-h, --help help for view
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm config](upterm_config.md) - Manage upterm configuration
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_host.md
================================================
## upterm host
Host a terminal session
### Synopsis
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.
```
upterm host [flags]
```
### Examples
```
# 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
```
### Options
```
--accept Automatically accept client connections without prompts.
--allow-local-tcp-forwarding Allow clients to use SSH local TCP forwarding (ssh -L) through the hosted session, reaching TCP destinations visible to the host.
--authorized-keys string Specify a authorize_keys file listing authorized public keys for connection.
--codeberg-user strings Authorize specified Codeberg users by allowing their public keys to connect.
-f, --force-command string Enforce a specified command for clients to join, and link the command's input/output to the client's terminal.
--github-user strings 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.
--gitlab-user strings Authorize specified GitLab users by allowing their public keys to connect.
-h, --help help for host
--hide-client-ip Hide client IP addresses from output (auto-enabled in CI environments).
--known-hosts string Specify a file containing known keys for remote hosts (required). (default "/Users/owen/.ssh/known_hosts")
--no-sftp Disable file transfer via SFTP/SCP. By default, clients can transfer files with the same access as the terminal session.
-i, --private-key strings Specify private key files for public key authentication with the upterm server (required). (default [/Users/owen/.ssh/id_ed25519])
-r, --read-only Host a read-only session, preventing client interaction. Also restricts SFTP to download-only.
--server string Specify the upterm server address (required). Supported protocols: ssh, ws, wss. (default "ssh://uptermd.upterm.dev:22")
--skip-host-key-check 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.
--srht-user strings Authorize specified SourceHut users by allowing their public keys to connect.
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm](upterm.md) - Instant Terminal Sharing
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_proxy.md
================================================
## upterm proxy
Proxy a terminal session via WebSocket
### Synopsis
Proxy a terminal session via WebSocket, to be used alongside SSH ProxyCommand.
```
upterm proxy [flags]
```
### Examples
```
# 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
```
### Options
```
-h, --help help for proxy
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm](upterm.md) - Instant Terminal Sharing
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_session.md
================================================
## upterm session
Display and manage terminal sessions
### Options
```
-h, --help help for session
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm](upterm.md) - Instant Terminal Sharing
* [upterm session current](upterm_session_current.md) - Display the current terminal session
* [upterm session info](upterm_session_info.md) - Display terminal session by name
* [upterm session list](upterm_session_list.md) - List shared sessions
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_session_current.md
================================================
## upterm session current
Display the current terminal session
### Synopsis
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: /run/user/1000/upterm
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
```
upterm session current [flags]
```
### Examples
```
# 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}}'
```
### Options
```
--admin-socket string Admin socket path (required).
-h, --help help for current
--hide-client-ip Hide client IP addresses from output (auto-enabled in CI environments).
-o, --output string Output format: json or go-template='...'
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm session](upterm_session.md) - Display and manage terminal sessions
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_session_info.md
================================================
## upterm session info
Display terminal session by name
### Synopsis
Display terminal session by name.
```
upterm session info [flags]
```
### Examples
```
# Display session by name:
upterm session info NAME
```
### Options
```
-h, --help help for info
--hide-client-ip Hide client IP addresses from output (auto-enabled in CI environments).
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm session](upterm_session.md) - Display and manage terminal sessions
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_session_list.md
================================================
## upterm session list
List shared sessions
### Synopsis
List shared sessions.
Sockets are stored in: /run/user/1000/upterm
Follows the XDG Base Directory Specification with fallback to $HOME/.upterm
in constrained environments where XDG directories are unavailable.
```
upterm session list [flags]
```
### Examples
```
# List shared sessions:
upterm session list
```
### Options
```
-h, --help help for list
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm session](upterm_session.md) - Display and manage terminal sessions
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_upgrade.md
================================================
## upterm upgrade
Upgrade the CLI
```
upterm upgrade [flags]
```
### Examples
```
# Upgrade to the latest version:
upterm upgrade
# Upgrade to a specific version:
upterm upgrade 0.2.0
```
### Options
```
-h, --help help for upgrade
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm](upterm.md) - Instant Terminal Sharing
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: docs/upterm_version.md
================================================
## upterm version
Show version
```
upterm version [flags]
```
### Options
```
-h, --help help for version
```
### Options inherited from parent commands
```
--debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
```
### SEE ALSO
* [upterm](upterm.md) - Instant Terminal Sharing
###### Auto generated by spf13/cobra on 3-May-2026
================================================
FILE: etc/completion/upterm.bash_completion.sh
================================================
# bash completion for upterm -*- shell-script -*-
__upterm_debug()
{
if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then
echo "$*" >> "${BASH_COMP_DEBUG_FILE}"
fi
}
# Homebrew on Macs have version 1.3 of bash-completion which doesn't include
# _init_completion. This is a very minimal version of that function.
__upterm_init_completion()
{
COMPREPLY=()
_get_comp_words_by_ref "$@" cur prev words cword
}
__upterm_index_of_word()
{
local w word=$1
shift
index=0
for w in "$@"; do
[[ $w = "$word" ]] && return
index=$((index+1))
done
index=-1
}
__upterm_contains_word()
{
local w word=$1; shift
for w in "$@"; do
[[ $w = "$word" ]] && return
done
return 1
}
__upterm_handle_go_custom_completion()
{
__upterm_debug "${FUNCNAME[0]}: cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}"
local shellCompDirectiveError=1
local shellCompDirectiveNoSpace=2
local shellCompDirectiveNoFileComp=4
local shellCompDirectiveFilterFileExt=8
local shellCompDirectiveFilterDirs=16
local out requestComp lastParam lastChar comp directive args
# Prepare the command to request completions for the program.
# Calling ${words[0]} instead of directly upterm allows handling aliases
args=("${words[@]:1}")
# Disable ActiveHelp which is not supported for bash completion v1
requestComp="UPTERM_ACTIVE_HELP=0 ${words[0]} __completeNoDesc ${args[*]}"
lastParam=${words[$((${#words[@]}-1))]}
lastChar=${lastParam:$((${#lastParam}-1)):1}
__upterm_debug "${FUNCNAME[0]}: lastParam ${lastParam}, lastChar ${lastChar}"
if [ -z "${cur}" ] && [ "${lastChar}" != "=" ]; then
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go method.
__upterm_debug "${FUNCNAME[0]}: Adding extra empty parameter"
requestComp="${requestComp} \"\""
fi
__upterm_debug "${FUNCNAME[0]}: calling ${requestComp}"
# Use eval to handle any environment variables and such
out=$(eval "${requestComp}" 2>/dev/null)
# Extract the directive integer at the very end of the output following a colon (:)
directive=${out##*:}
# Remove the directive
out=${out%:*}
if [ "${directive}" = "${out}" ]; then
# There is not directive specified
directive=0
fi
__upterm_debug "${FUNCNAME[0]}: the completion directive is: ${directive}"
__upterm_debug "${FUNCNAME[0]}: the completions are: ${out}"
if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then
# Error code. No completion.
__upterm_debug "${FUNCNAME[0]}: received error from custom completion go code"
return
else
if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then
if [[ $(type -t compopt) = "builtin" ]]; then
__upterm_debug "${FUNCNAME[0]}: activating no space"
compopt -o nospace
fi
fi
if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then
if [[ $(type -t compopt) = "builtin" ]]; then
__upterm_debug "${FUNCNAME[0]}: activating no file completion"
compopt +o default
fi
fi
fi
if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then
# File extension filtering
local fullFilter filter filteringCmd
# Do not use quotes around the $out variable or else newline
# characters will be kept.
for filter in ${out}; do
fullFilter+="$filter|"
done
filteringCmd="_filedir $fullFilter"
__upterm_debug "File filtering command: $filteringCmd"
$filteringCmd
elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then
# File completion for directories only
local subdir
# Use printf to strip any trailing newline
subdir=$(printf "%s" "${out}")
if [ -n "$subdir" ]; then
__upterm_debug "Listing directories in $subdir"
__upterm_handle_subdirs_in_dir_flag "$subdir"
else
__upterm_debug "Listing directories in ."
_filedir -d
fi
else
while IFS='' read -r comp; do
COMPREPLY+=("$comp")
done < <(compgen -W "${out}" -- "$cur")
fi
}
__upterm_handle_reply()
{
__upterm_debug "${FUNCNAME[0]}"
local comp
case $cur in
-*)
if [[ $(type -t compopt) = "builtin" ]]; then
compopt -o nospace
fi
local allflags
if [ ${#must_have_one_flag[@]} -ne 0 ]; then
allflags=("${must_have_one_flag[@]}")
else
allflags=("${flags[*]} ${two_word_flags[*]}")
fi
while IFS='' read -r comp; do
COMPREPLY+=("$comp")
done < <(compgen -W "${allflags[*]}" -- "$cur")
if [[ $(type -t compopt) = "builtin" ]]; then
[[ "${COMPREPLY[0]}" == *= ]] || compopt +o nospace
fi
# complete after --flag=abc
if [[ $cur == *=* ]]; then
if [[ $(type -t compopt) = "builtin" ]]; then
compopt +o nospace
fi
local index flag
flag="${cur%=*}"
__upterm_index_of_word "${flag}" "${flags_with_completion[@]}"
COMPREPLY=()
if [[ ${index} -ge 0 ]]; then
PREFIX=""
cur="${cur#*=}"
${flags_completion[${index}]}
if [ -n "${ZSH_VERSION:-}" ]; then
# zsh completion needs --flag= prefix
eval "COMPREPLY=( \"\${COMPREPLY[@]/#/${flag}=}\" )"
fi
fi
fi
if [[ -z "${flag_parsing_disabled}" ]]; then
# If flag parsing is enabled, we have completed the flags and can return.
# If flag parsing is disabled, we may not know all (or any) of the flags, so we fallthrough
# to possibly call handle_go_custom_completion.
return 0;
fi
;;
esac
# check if we are handling a flag with special work handling
local index
__upterm_index_of_word "${prev}" "${flags_with_completion[@]}"
if [[ ${index} -ge 0 ]]; then
${flags_completion[${index}]}
return
fi
# we are parsing a flag and don't have a special handler, no completion
if [[ ${cur} != "${words[cword]}" ]]; then
return
fi
local completions
completions=("${commands[@]}")
if [[ ${#must_have_one_noun[@]} -ne 0 ]]; then
completions+=("${must_have_one_noun[@]}")
elif [[ -n "${has_completion_function}" ]]; then
# if a go completion function is provided, defer to that function
__upterm_handle_go_custom_completion
fi
if [[ ${#must_have_one_flag[@]} -ne 0 ]]; then
completions+=("${must_have_one_flag[@]}")
fi
while IFS='' read -r comp; do
COMPREPLY+=("$comp")
done < <(compgen -W "${completions[*]}" -- "$cur")
if [[ ${#COMPREPLY[@]} -eq 0 && ${#noun_aliases[@]} -gt 0 && ${#must_have_one_noun[@]} -ne 0 ]]; then
while IFS='' read -r comp; do
COMPREPLY+=("$comp")
done < <(compgen -W "${noun_aliases[*]}" -- "$cur")
fi
if [[ ${#COMPREPLY[@]} -eq 0 ]]; then
if declare -F __upterm_custom_func >/dev/null; then
# try command name qualified custom func
__upterm_custom_func
else
# otherwise fall back to unqualified for compatibility
declare -F __custom_func >/dev/null && __custom_func
fi
fi
# available in bash-completion >= 2, not always present on macOS
if declare -F __ltrim_colon_completions >/dev/null; then
__ltrim_colon_completions "$cur"
fi
# If there is only 1 completion and it is a flag with an = it will be completed
# but we don't want a space after the =
if [[ "${#COMPREPLY[@]}" -eq "1" ]] && [[ $(type -t compopt) = "builtin" ]] && [[ "${COMPREPLY[0]}" == --*= ]]; then
compopt -o nospace
fi
}
# The arguments should be in the form "ext1|ext2|extn"
__upterm_handle_filename_extension_flag()
{
local ext="$1"
_filedir "@(${ext})"
}
__upterm_handle_subdirs_in_dir_flag()
{
local dir="$1"
pushd "${dir}" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return
}
__upterm_handle_flag()
{
__upterm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}"
# if a command required a flag, and we found it, unset must_have_one_flag()
local flagname=${words[c]}
local flagvalue=""
# if the word contained an =
if [[ ${words[c]} == *"="* ]]; then
flagvalue=${flagname#*=} # take in as flagvalue after the =
flagname=${flagname%=*} # strip everything after the =
flagname="${flagname}=" # but put the = back
fi
__upterm_debug "${FUNCNAME[0]}: looking for ${flagname}"
if __upterm_contains_word "${flagname}" "${must_have_one_flag[@]}"; then
must_have_one_flag=()
fi
# if you set a flag which only applies to this command, don't show subcommands
if __upterm_contains_word "${flagname}" "${local_nonpersistent_flags[@]}"; then
commands=()
fi
# keep flag value with flagname as flaghash
# flaghash variable is an associative array which is only supported in bash > 3.
if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
if [ -n "${flagvalue}" ] ; then
flaghash[${flagname}]=${flagvalue}
elif [ -n "${words[ $((c+1)) ]}" ] ; then
flaghash[${flagname}]=${words[ $((c+1)) ]}
else
flaghash[${flagname}]="true" # pad "true" for bool flag
fi
fi
# skip the argument to a two word flag
if [[ ${words[c]} != *"="* ]] && __upterm_contains_word "${words[c]}" "${two_word_flags[@]}"; then
__upterm_debug "${FUNCNAME[0]}: found a flag ${words[c]}, skip the next argument"
c=$((c+1))
# if we are looking for a flags value, don't show commands
if [[ $c -eq $cword ]]; then
commands=()
fi
fi
c=$((c+1))
}
__upterm_handle_noun()
{
__upterm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}"
if __upterm_contains_word "${words[c]}" "${must_have_one_noun[@]}"; then
must_have_one_noun=()
elif __upterm_contains_word "${words[c]}" "${noun_aliases[@]}"; then
must_have_one_noun=()
fi
nouns+=("${words[c]}")
c=$((c+1))
}
__upterm_handle_command()
{
__upterm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}"
local next_command
if [[ -n ${last_command} ]]; then
next_command="_${last_command}_${words[c]//:/__}"
else
if [[ $c -eq 0 ]]; then
next_command="_upterm_root_command"
else
next_command="_${words[c]//:/__}"
fi
fi
c=$((c+1))
__upterm_debug "${FUNCNAME[0]}: looking for ${next_command}"
declare -F "$next_command" >/dev/null && $next_command
}
__upterm_handle_word()
{
if [[ $c -ge $cword ]]; then
__upterm_handle_reply
return
fi
__upterm_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}"
if [[ "${words[c]}" == -* ]]; then
__upterm_handle_flag
elif __upterm_contains_word "${words[c]}" "${commands[@]}"; then
__upterm_handle_command
elif [[ $c -eq 0 ]]; then
__upterm_handle_command
elif __upterm_contains_word "${words[c]}" "${command_aliases[@]}"; then
# aliashash variable is an associative array which is only supported in bash > 3.
if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
words[c]=${aliashash[${words[c]}]}
__upterm_handle_command
else
__upterm_handle_noun
fi
else
__upterm_handle_noun
fi
__upterm_handle_word
}
_upterm_config_edit()
{
last_command="upterm_config_edit"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_config_help()
{
last_command="upterm_config_help"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
has_completion_function=1
noun_aliases=()
}
_upterm_config_path()
{
last_command="upterm_config_path"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_config_view()
{
last_command="upterm_config_view"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_config()
{
last_command="upterm_config"
command_aliases=()
commands=()
commands+=("edit")
commands+=("help")
commands+=("path")
commands+=("view")
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_help()
{
last_command="upterm_help"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
has_completion_function=1
noun_aliases=()
}
_upterm_host()
{
last_command="upterm_host"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--accept")
flags+=("--allow-local-tcp-forwarding")
flags+=("--authorized-keys=")
two_word_flags+=("--authorized-keys")
flags+=("--codeberg-user=")
two_word_flags+=("--codeberg-user")
flags+=("--force-command=")
two_word_flags+=("--force-command")
two_word_flags+=("-f")
flags+=("--github-user=")
two_word_flags+=("--github-user")
flags+=("--gitlab-user=")
two_word_flags+=("--gitlab-user")
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--hide-client-ip")
flags+=("--known-hosts=")
two_word_flags+=("--known-hosts")
flags+=("--no-sftp")
flags+=("--private-key=")
two_word_flags+=("--private-key")
two_word_flags+=("-i")
flags+=("--read-only")
flags+=("-r")
flags+=("--server=")
two_word_flags+=("--server")
flags+=("--skip-host-key-check")
flags+=("--srht-user=")
two_word_flags+=("--srht-user")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_proxy()
{
last_command="upterm_proxy"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_session_current()
{
last_command="upterm_session_current"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--admin-socket=")
two_word_flags+=("--admin-socket")
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--hide-client-ip")
local_nonpersistent_flags+=("--hide-client-ip")
flags+=("--output=")
two_word_flags+=("--output")
two_word_flags+=("-o")
local_nonpersistent_flags+=("--output")
local_nonpersistent_flags+=("--output=")
local_nonpersistent_flags+=("-o")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_session_help()
{
last_command="upterm_session_help"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
has_completion_function=1
noun_aliases=()
}
_upterm_session_info()
{
last_command="upterm_session_info"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--hide-client-ip")
local_nonpersistent_flags+=("--hide-client-ip")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_session_list()
{
last_command="upterm_session_list"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_session()
{
last_command="upterm_session"
command_aliases=()
commands=()
commands+=("current")
if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
command_aliases+=("c")
aliashash["c"]="current"
fi
commands+=("help")
commands+=("info")
if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
command_aliases+=("i")
aliashash["i"]="info"
fi
commands+=("list")
if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
command_aliases+=("l")
aliashash["l"]="list"
command_aliases+=("ls")
aliashash["ls"]="list"
fi
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_upgrade()
{
last_command="upterm_upgrade"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_version()
{
last_command="upterm_version"
command_aliases=()
commands=()
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
flags+=("--debug")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
_upterm_root_command()
{
last_command="upterm"
command_aliases=()
commands=()
commands+=("config")
commands+=("help")
commands+=("host")
commands+=("proxy")
commands+=("session")
if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
command_aliases+=("se")
aliashash["se"]="session"
fi
commands+=("upgrade")
commands+=("version")
flags=()
two_word_flags=()
local_nonpersistent_flags=()
flags_with_completion=()
flags_completion=()
flags+=("--debug")
flags+=("--help")
flags+=("-h")
local_nonpersistent_flags+=("--help")
local_nonpersistent_flags+=("-h")
must_have_one_flag=()
must_have_one_noun=()
noun_aliases=()
}
__start_upterm()
{
local cur prev words cword split
declare -A flaghash 2>/dev/null || :
declare -A aliashash 2>/dev/null || :
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion -s || return
else
__upterm_init_completion -n "=" || return
fi
local c=0
local flag_parsing_disabled=
local flags=()
local two_word_flags=()
local local_nonpersistent_flags=()
local flags_with_completion=()
local flags_completion=()
local commands=("upterm")
local command_aliases=()
local must_have_one_flag=()
local must_have_one_noun=()
local has_completion_function=""
local last_command=""
local nouns=()
local noun_aliases=()
__upterm_handle_word
}
if [[ $(type -t compopt) = "builtin" ]]; then
complete -o default -F __start_upterm upterm
else
complete -o default -o nospace -F __start_upterm upterm
fi
# ex: ts=4 sw=4 et filetype=sh
================================================
FILE: etc/completion/upterm.zsh_completion
================================================
#compdef upterm
compdef _upterm upterm
# zsh completion for upterm -*- shell-script -*-
__upterm_debug()
{
local file="$BASH_COMP_DEBUG_FILE"
if [[ -n ${file} ]]; then
echo "$*" >> "${file}"
fi
}
_upterm()
{
local shellCompDirectiveError=1
local shellCompDirectiveNoSpace=2
local shellCompDirectiveNoFileComp=4
local shellCompDirectiveFilterFileExt=8
local shellCompDirectiveFilterDirs=16
local shellCompDirectiveKeepOrder=32
local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder
local -a completions
__upterm_debug "\n========= starting completion logic =========="
__upterm_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}"
# The user could have moved the cursor backwards on the command-line.
# We need to trigger completion from the $CURRENT location, so we need
# to truncate the command-line ($words) up to the $CURRENT location.
# (We cannot use $CURSOR as its value does not work when a command is an alias.)
words=("${=words[1,CURRENT]}")
__upterm_debug "Truncated words[*]: ${words[*]},"
lastParam=${words[-1]}
lastChar=${lastParam[-1]}
__upterm_debug "lastParam: ${lastParam}, lastChar: ${lastChar}"
# For zsh, when completing a flag with an = (e.g., upterm -n=<TAB>)
# completions must be prefixed with the flag
setopt local_options BASH_REMATCH
if [[ "${lastParam}" =~ '-.*=' ]]; then
# We are dealing with a flag with an =
flagPrefix="-P ${BASH_REMATCH}"
fi
# Prepare the command to obtain completions
requestComp="${words[1]} __complete ${words[2,-1]}"
if [ "${lastChar}" = "" ]; then
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go completion code.
__upterm_debug "Adding extra empty parameter"
requestComp="${requestComp} \"\""
fi
__upterm_debug "About to call: eval ${requestComp}"
# Use eval to handle any environment variables and such
out=$(eval ${requestComp} 2>/dev/null)
__upterm_debug "completion output: ${out}"
# Extract the directive integer following a : from the last line
local lastLine
while IFS='\n' read -r line; do
lastLine=${line}
done < <(printf "%s\n" "${out[@]}")
__upterm_debug "last line: ${lastLine}"
if [ "${lastLine[1]}" = : ]; then
directive=${lastLine[2,-1]}
# Remove the directive including the : and the newline
local suffix
(( suffix=${#lastLine}+2))
out=${out[1,-$suffix]}
else
# There is no directive specified. Leave $out as is.
__upterm_debug "No directive found. Setting do default"
directive=0
fi
__upterm_debug "directive: ${directive}"
__upterm_debug "completions: ${out}"
__upterm_debug "flagPrefix: ${flagPrefix}"
if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then
__upterm_debug "Completion received error. Ignoring completions."
return
fi
local activeHelpMarker="_activeHelp_ "
local endIndex=${#activeHelpMarker}
local startIndex=$((${#activeHelpMarker}+1))
local hasActiveHelp=0
while IFS='\n' read -r comp; do
# Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker)
if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then
__upterm_debug "ActiveHelp found: $comp"
comp="${comp[$startIndex,-1]}"
if [ -n "$comp" ]; then
compadd -x "${comp}"
__upterm_debug "ActiveHelp will need delimiter"
hasActiveHelp=1
fi
continue
fi
if [ -n "$comp" ]; then
# If requested, completions are returned with a description.
# The description is preceded by a TAB character.
# For zsh's _describe, we need to use a : instead of a TAB.
# We first need to escape any : as part of the completion itself.
comp=${comp//:/\\:}
local tab="$(printf '\t')"
comp=${comp//$tab/:}
__upterm_debug "Adding completion: ${comp}"
completions+=${comp}
lastComp=$comp
fi
done < <(printf "%s\n" "${out[@]}")
# Add a delimiter after the activeHelp statements, but only if:
# - there are completions following the activeHelp statements, or
# - file completion will be performed (so there will be choices after the activeHelp)
if [ $hasActiveHelp -eq 1 ]; then
if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then
__upterm_debug "Adding activeHelp delimiter"
compadd -x "--"
hasActiveHelp=0
fi
fi
if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then
__upterm_debug "Activating nospace."
noSpace="-S ''"
fi
if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then
__upterm_debug "Activating keep order."
keepOrder="-V"
fi
if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then
# File extension filtering
local filteringCmd
filteringCmd='_files'
for filter in ${completions[@]}; do
if [ ${filter[1]} != '*' ]; then
# zsh requires a glob pattern to do file filtering
filter="\*.$filter"
fi
filteringCmd+=" -g $filter"
done
filteringCmd+=" ${flagPrefix}"
__upterm_debug "File filtering command: $filteringCmd"
_arguments '*:filename:'"$filteringCmd"
elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then
# File completion for directories only
local subdir
subdir="${completions[1]}"
if [ -n "$subdir" ]; then
__upterm_debug "Listing directories in $subdir"
pushd "${subdir}" >/dev/null 2>&1
else
__upterm_debug "Listing directories in ."
fi
local result
_arguments '*:dirname:_files -/'" ${flagPrefix}"
result=$?
if [ -n "$subdir" ]; then
popd >/dev/null 2>&1
fi
return $result
else
__upterm_debug "Calling _describe"
if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then
__upterm_debug "_describe found some completions"
# Return the success of having called _describe
return 0
else
__upterm_debug "_describe did not find completions."
__upterm_debug "Checking if we should do file completion."
if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then
__upterm_debug "deactivating file completion"
# We must return an error code here to let zsh know that there were no
# completions found by _describe; this is what will trigger other
# matching algorithms to attempt to find completions.
# For example zsh can match letters in the middle of words.
return 1
else
# Perform file completion
__upterm_debug "Activating file completion"
# We must return the result of this command, so it must be the
# last command, or else we must store its result to return it.
_arguments '*:filename:_files'" ${flagPrefix}"
fi
fi
fi
}
# don't run the completion function when being source-ed or eval-ed
if [ "$funcstack[1]" = "_upterm" ]; then
_upterm
fi
================================================
FILE: etc/man/man1/upterm-config-edit.1
================================================
.nh
.TH "UPTERM" "1" "May 2026" "Upterm 0.0.0+dev" "Upterm Manual"
.SH NAME
upterm-config-edit - Edit the config file
.SH SYNOPSIS
\fBupterm config edit [flags]\fP
.SH DESCRIPTION
Edit the config file in your default editor.
.PP
Config file: /home/user/.config/upterm/config.yaml
.PP
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.
.PP
The config directory is created automatically if it doesn't exist.
.SH OPTIONS
\fB-h\fP, \fB--help\fP[=false]
help for edit
.SH OPTIONS INHERITED FROM PARENT COMMANDS
\fB--debug\fP[=false]
enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
.SH EXAMPLE
.EX
# Edit config file:
upterm config edit
# Use a specific editor:
EDITOR=nano upterm config edit
.EE
.SH SEE ALSO
\fBupterm-config(1)\fP
.SH HISTORY
3-May-2026 Auto generated by spf13/cobra
================================================
FILE: etc/man/man1/upterm-config-path.1
================================================
.nh
.TH "UPTERM" "1" "May 2026" "Upterm 0.0.0+dev" "Upterm Manual"
.SH NAME
upterm-config-path - Show the path to the config file
.SH SYNOPSIS
\fBupterm config path [flags]\fP
.SH DESCRIPTION
Show the path to the config file.
.PP
Config file: /home/user/.config/upterm/config.yaml
.PP
The config file is optional and created manually by users.
.SH OPTIONS
\fB-h\fP, \fB--help\fP[=false]
help for path
.SH OPTIONS INHERITED FROM PARENT COMMANDS
\fB--debug\fP[=false]
enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log).
.SH EXAMPLE
.EX
# Show config file path:
upterm config path
# Create config file directory:
mkdir -p "$(dirname "$(upterm config path)")"
.EE
.SH SEE ALSO
\fBupterm-config(1)\fP
.SH HISTORY
3-May-2026 Auto generated by spf13/cobra
================================================
FILE: etc/man/man1/upterm-config-view.1
================================================
.nh
.TH "UPTERM" "1" "May 2026" "Upterm 0.0.0+dev" "Upterm Manual"
.SH NAME
upterm-config-view - View the config file contents
.SH SYNOPSIS
\fBupterm config view [flags]\fP
.SH DESCRIPTION
View the config file contents.
.PP
Config file: /home/user/.config/upterm/config.yaml
.PP
If the config file exists
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
SYMBOL INDEX (911 symbols across 92 files)
FILE: cmd/gendoc/main.go
function main (line 12) | func main() {
FILE: cmd/upterm/command/config.go
function configCmd (line 14) | func configCmd() *cobra.Command {
function configPathCmd (line 39) | func configPathCmd() *cobra.Command {
function configViewCmd (line 60) | func configViewCmd() *cobra.Command {
function configEditCmd (line 82) | func configEditCmd() *cobra.Command {
function configPathRunE (line 107) | func configPathRunE(c *cobra.Command, args []string) error {
function configViewRunE (line 113) | func configViewRunE(c *cobra.Command, args []string) error {
function configEditRunE (line 135) | func configEditRunE(c *cobra.Command, args []string) error {
function getEditor (line 174) | func getEditor() string {
function validateConfig (line 199) | func validateConfig(path string) error {
function exampleConfig (line 206) | func exampleConfig() string {
FILE: cmd/upterm/command/host.go
type UserDiscardedError (line 29) | type UserDiscardedError struct
method Error (line 31) | func (e UserDiscardedError) Error() string {
type UserInterruptedError (line 36) | type UserInterruptedError struct
method Error (line 38) | func (e UserInterruptedError) Error() string {
type SilentError (line 44) | type SilentError struct
method Error (line 48) | func (e SilentError) Error() string {
method Unwrap (line 52) | func (e SilentError) Unwrap() error {
function hostCmd (line 73) | func hostCmd() *cobra.Command {
function validateShareRequiredFlags (line 136) | func validateShareRequiredFlags(c *cobra.Command, args []string) error {
function shareRunE (line 178) | func shareRunE(c *cobra.Command, args []string) error {
function clientJoinedCallback (line 319) | func clientJoinedCallback(c *api.Client) {
function clientLeftCallback (line 323) | func clientLeftCallback(c *api.Client) {
function notifyBody (line 327) | func notifyBody(c *api.Client) string {
function displaySessionCallback (line 331) | func displaySessionCallback(ctx context.Context, session *api.GetSession...
function defaultPrivateKeys (line 372) | func defaultPrivateKeys(homeDir string) []string {
function defaultKnownHost (line 393) | func defaultKnownHost(homeDir string) string {
FILE: cmd/upterm/command/host_test.go
function Test_validateShareRequiredFlags_readOnlyAndLocalTCPForwarding (line 10) | func Test_validateShareRequiredFlags_readOnlyAndLocalTCPForwarding(t *te...
function Test_parseURL (line 55) | func Test_parseURL(t *testing.T) {
FILE: cmd/upterm/command/host_unix.go
function getDefaultShell (line 10) | func getDefaultShell() string {
FILE: cmd/upterm/command/host_windows.go
function getDefaultShell (line 11) | func getDefaultShell() string {
FILE: cmd/upterm/command/internal/tui/host_session.go
type HostSessionConfirmResult (line 10) | type HostSessionConfirmResult
constant HostSessionConfirmAccepted (line 14) | HostSessionConfirmAccepted HostSessionConfirmResult = iota
constant HostSessionConfirmRejected (line 16) | HostSessionConfirmRejected
constant HostSessionConfirmInterrupted (line 18) | HostSessionConfirmInterrupted
type HostSessionModel (line 24) | type HostSessionModel struct
method Init (line 58) | func (m HostSessionModel) Init() tea.Cmd {
method Update (line 66) | func (m HostSessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 96) | func (m HostSessionModel) View() string {
method Result (line 131) | func (m HostSessionModel) Result() HostSessionConfirmResult {
type sessionState (line 33) | type sessionState
constant stateWaitingForConfirm (line 37) | stateWaitingForConfirm sessionState = iota
constant stateDone (line 39) | stateDone
function NewHostSessionModel (line 43) | func NewHostSessionModel(detail SessionDetail, autoAccept bool) HostSess...
FILE: cmd/upterm/command/internal/tui/session_detail.go
function IsTTY (line 15) | func IsTTY() bool {
function getTermWidth (line 20) | func getTermWidth() int {
function RunModel (line 30) | func RunModel(model tea.Model) (tea.Model, error) {
type SessionDetail (line 41) | type SessionDetail struct
function FormatSessionDetail (line 58) | func FormatSessionDetail(detail SessionDetail) string {
function PrintSessionDetail (line 63) | func PrintSessionDetail(detail SessionDetail) {
function wrapLines (line 70) | func wrapLines(text string, width int) []string {
function renderWrappedRow (line 82) | func renderWrappedRow(b *strings.Builder, label string, value string, la...
function renderSessionDetail (line 99) | func renderSessionDetail(detail SessionDetail, width int) string {
FILE: cmd/upterm/command/internal/tui/session_detail_test.go
function Test_wrapLines (line 11) | func Test_wrapLines(t *testing.T) {
function Test_renderWrappedRow_multiline (line 54) | func Test_renderWrappedRow_multiline(t *testing.T) {
function Test_FormatSessionDetail_authorizedKeys (line 78) | func Test_FormatSessionDetail_authorizedKeys(t *testing.T) {
FILE: cmd/upterm/command/internal/tui/session_list.go
type SessionListModel (line 12) | type SessionListModel struct
method Init (line 97) | func (m SessionListModel) Init() tea.Cmd {
method Update (line 101) | func (m SessionListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method View (line 145) | func (m SessionListModel) View() string {
function calculateColumns (line 27) | func calculateColumns(width int) []table.Column {
function NewSessionListModel (line 52) | func NewSessionListModel(sessions []SessionDetail) SessionListModel {
FILE: cmd/upterm/command/privacy.go
function shouldHideClientIP (line 24) | func shouldHideClientIP() bool {
function isCI (line 36) | func isCI() bool {
FILE: cmd/upterm/command/proxy.go
function proxyCmd (line 16) | func proxyCmd() *cobra.Command {
function proxyRunE (line 32) | func proxyRunE(c *cobra.Command, args []string) error {
FILE: cmd/upterm/command/root.go
function Root (line 16) | func Root() *cobra.Command {
function bindFlagsToEnv (line 116) | func bindFlagsToEnv(cmd *cobra.Command) error {
function toString (line 165) | func toString(val any) string {
FILE: cmd/upterm/command/session.go
type sessionTemplateData (line 29) | type sessionTemplateData struct
function sessionCmd (line 37) | func sessionCmd() *cobra.Command {
function list (line 50) | func list() *cobra.Command {
function show (line 70) | func show() *cobra.Command {
function current (line 86) | func current() *cobra.Command {
function listRunE (line 129) | func listRunE(c *cobra.Command, args []string) error {
function fetchSessionDetail (line 141) | func fetchSessionDetail(ctx context.Context, adminSocket string) (tui.Se...
function infoRunE (line 149) | func infoRunE(c *cobra.Command, args []string) error {
function currentRunE (line 164) | func currentRunE(c *cobra.Command, args []string) error {
function outputSession (line 180) | func outputSession(ctx context.Context, adminSocket, format string) error {
function listSessions (line 226) | func listSessions(ctx context.Context, dir string) ([]tui.SessionDetail,...
function parseURL (line 260) | func parseURL(str string) (u *url.URL, scheme string, host string, port ...
function buildSessionDetail (line 289) | func buildSessionDetail(sess *api.GetSessionResponse) (tui.SessionDetail...
function clientDesc (line 354) | func clientDesc(addr, clientVer, fingerprint string) string {
function currentAdminSocketFile (line 361) | func currentAdminSocketFile() string {
function session (line 365) | func session(ctx context.Context, adminSocket string) (*api.GetSessionRe...
function validateCurrentRequiredFlags (line 374) | func validateCurrentRequiredFlags(c *cobra.Command, args []string) error {
function displayAuthorizedKeys (line 387) | func displayAuthorizedKeys(keys []*api.AuthorizedKey) string {
FILE: cmd/upterm/command/sftp_permission.go
type DialogPermissionChecker (line 14) | type DialogPermissionChecker struct
method CheckPermission (line 27) | func (d *DialogPermissionChecker) CheckPermission(op sftp.Operation, c...
method allowSession (line 66) | func (d *DialogPermissionChecker) allowSession(sessionID string) {
method isSessionAllowed (line 70) | func (d *DialogPermissionChecker) isSessionAllowed(sessionID string) b...
method allowFile (line 75) | func (d *DialogPermissionChecker) allowFile(sessionID, path string) {
method isFileAllowed (line 80) | func (d *DialogPermissionChecker) isFileAllowed(sessionID, path string...
method ClearSession (line 87) | func (d *DialogPermissionChecker) ClearSession(sessionID string) {
method showDialog (line 101) | func (d *DialogPermissionChecker) showDialog(op sftp.Operation, client...
type AutoAllowPermissionChecker (line 174) | type AutoAllowPermissionChecker struct
method CheckPermission (line 177) | func (a *AutoAllowPermissionChecker) CheckPermission(op sftp.Operation...
method ClearSession (line 182) | func (a *AutoAllowPermissionChecker) ClearSession(sessionID string) {}
FILE: cmd/upterm/command/upgrade.go
function upgradeCmd (line 20) | func upgradeCmd() *cobra.Command {
function upgradeRunE (line 35) | func upgradeRunE(c *cobra.Command, args []string) error {
function trimVPrefix (line 104) | func trimVPrefix(s string) string {
type release (line 108) | type release struct
method FindTarballWithVersion (line 112) | func (r *release) FindTarballWithVersion(os, arch string) *update.Asset {
type store (line 124) | type store struct
method GetRelease (line 130) | func (s *store) GetRelease(version string) (*update.Release, error) {
method LatestReleases (line 149) | func (s *store) LatestReleases() ([]*update.Release, error) {
function githubRelease (line 165) | func githubRelease(r *ggh.RepositoryRelease) *update.Release {
FILE: cmd/upterm/command/version.go
function versionCmd (line 8) | func versionCmd() *cobra.Command {
FILE: cmd/upterm/main.go
function main (line 11) | func main() {
FILE: cmd/uptermd-fly/main.go
function main (line 11) | func main() {
FILE: cmd/uptermd/command/root.go
function Root (line 18) | func Root() *cobra.Command {
type rootCmd (line 53) | type rootCmd struct
method RunE (line 56) | func (cmd *rootCmd) RunE(c *cobra.Command, args []string) error {
function unmarshalFlags (line 88) | func unmarshalFlags(cmd *cobra.Command, opts interface{}) error {
FILE: cmd/uptermd/command/version.go
function versionCmd (line 8) | func versionCmd() *cobra.Command {
FILE: cmd/uptermd/main.go
function main (line 10) | func main() {
FILE: ftests/client_test.go
function testHostNoAuthorizedKeyAnyClientJoin (line 20) | func testHostNoAuthorizedKeyAnyClientJoin(t *testing.T, hostShareURL, ho...
function testClientAuthorizedKeyNotMatching (line 46) | func testClientAuthorizedKeyNotMatching(t *testing.T, hostShareURL, host...
function testClientNonExistingSession (line 77) | func testClientNonExistingSession(t *testing.T, hostShareURL, hostNodeAd...
function testClientAttachHostWithSameCommand (line 115) | func testClientAttachHostWithSameCommand(t *testing.T, hostShareURL, hos...
function testClientAttachHostWithDifferentCommand (line 168) | func testClientAttachHostWithDifferentCommand(t *testing.T, hostShareURL...
function testClientAttachReadOnly (line 223) | func testClientAttachReadOnly(t *testing.T, hostShareURL, hostNodeAddr, ...
function testClientLocalPortForward (line 300) | func testClientLocalPortForward(t *testing.T, hostShareURL, hostNodeAddr...
function testClientLocalPortForwardDisabled (line 404) | func testClientLocalPortForwardDisabled(t *testing.T, hostShareURL, host...
function getAndVerifySession (line 438) | func getAndVerifySession(t *testing.T, adminSocketFile string, wantHostU...
function checkSessionPayload (line 452) | func checkSessionPayload(t *testing.T, sess *api.GetSessionResponse, wan...
function testOldClientToNewConsulServer (line 462) | func testOldClientToNewConsulServer(t *testing.T, hostShareURL, hostNode...
function setupAdminSocket (line 512) | func setupAdminSocket(t *testing.T) string {
FILE: ftests/ftests_test.go
function getTestShell (line 47) | func getTestShell() []string {
constant serverStartupTimeout (line 63) | serverStartupTimeout = 3 * time.Second
constant unixSocketWaitTimeout (line 64) | unixSocketWaitTimeout = 3 * time.Second
constant keepAliveDuration (line 65) | keepAliveDuration = 2 * time.Second
constant sshAttachTimeout (line 66) | sshAttachTimeout = 500 * time.Millisecond
constant ServerPublicKeyContent (line 69) | ServerPublicKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA7wM3URd...
constant ServerPrivateKeyContent (line 70) | ServerPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----
constant HostPublicKeyContent (line 77) | HostPublicKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOA+rMcwWFP...
constant HostPrivateKeyContent (line 78) | HostPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----
constant ClientPublicKeyContent (line 85) | ClientPublicKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0EWrjdc...
constant ClientPrivateKeyContent (line 86) | ClientPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----
type FtestCase (line 101) | type FtestCase
type FtestSuite (line 136) | type FtestSuite struct
method SetupSuite (line 143) | func (suite *FtestSuite) SetupSuite() {
method TearDownSuite (line 157) | func (suite *FtestSuite) TearDownSuite() {
method TestAuth (line 166) | func (suite *FtestSuite) TestAuth() {
method TestSession (line 170) | func (suite *FtestSuite) TestSession() {
method TestConnection (line 174) | func (suite *FtestSuite) TestConnection() {
method TestCallbacks (line 178) | func (suite *FtestSuite) TestCallbacks() {
method TestBackwardCompatibility (line 182) | func (suite *FtestSuite) TestBackwardCompatibility() {
method runTestCategory (line 192) | func (suite *FtestSuite) runTestCategory(testCases []FtestCase) {
method runTestsForProtocol (line 202) | func (suite *FtestSuite) runTestsForProtocol(protocol string, testCase...
method getServerAddr (line 233) | func (suite *FtestSuite) getServerAddr(protocol string, server TestSer...
function TestEmbedded (line 241) | func TestEmbedded(t *testing.T) {
function TestConsul (line 245) | func TestConsul(t *testing.T) {
function mustParseURL (line 253) | func mustParseURL(urlStr string) *url.URL {
function funcName (line 261) | func funcName(i interface{}) string {
type TestServer (line 268) | type TestServer interface
function NewServerWithMode (line 275) | func NewServerWithMode(hostKey string, mode routing.Mode) (TestServer, e...
type Server (line 330) | type Server struct
method start (line 343) | func (s *Server) start() error {
method SSHAddr (line 413) | func (s *Server) SSHAddr() string {
method WSAddr (line 417) | func (s *Server) WSAddr() string {
method NodeAddr (line 421) | func (s *Server) NodeAddr() string {
method Shutdown (line 430) | func (s *Server) Shutdown() error {
type Host (line 444) | type Host struct
method Close (line 465) | func (c *Host) Close() {
method init (line 490) | func (c *Host) init() {
method Share (line 496) | func (c *Host) Share(url string) error {
method InputOutput (line 617) | func (c *Host) InputOutput() (chan string, chan string) {
type Client (line 621) | type Client struct
method init (line 633) | func (c *Client) init() {
method InputOutput (line 638) | func (c *Client) InputOutput() (chan string, chan string) {
method SFTP (line 644) | func (c *Client) SFTP() (*sftp.Client, error) {
method Close (line 651) | func (c *Client) Close() {
method JoinWithContext (line 686) | func (c *Client) JoinWithContext(ctx context.Context, session *api.Get...
method Join (line 810) | func (c *Client) Join(session *api.GetSessionResponse, clientJoinURL s...
function scanner (line 814) | func scanner(ch chan string) *bufio.Scanner {
function stripShellPrompt (line 830) | func stripShellPrompt(s string) string {
function scan (line 847) | func scan(s *bufio.Scanner) string {
function waitForUnixSocket (line 860) | func waitForUnixSocket(socket string, errCh chan error) error {
type writeFunc (line 882) | type writeFunc
method Write (line 884) | func (rf writeFunc) Write(p []byte) (n int, err error) { return rf(p) }
function authMethodsFromFiles (line 886) | func authMethodsFromFiles(privateKeys []string) ([]ssh.AuthMethod, error) {
function setupKeyPairs (line 900) | func setupKeyPairs() (func(), error) {
function writeTempFile (line 921) | func writeTempFile(name, content string) (string, error) {
FILE: ftests/host_test.go
function testHostClientCallback (line 16) | func testHostClientCallback(t *testing.T, hostShareURL, hostNodeAddr, cl...
function testHostSessionCreatedCallback (line 93) | func testHostSessionCreatedCallback(t *testing.T, hostShareURL, hostNode...
function testHostFailToShareWithoutPrivateKey (line 119) | func testHostFailToShareWithoutPrivateKey(t *testing.T, hostShareURL, ho...
FILE: ftests/sftp_test.go
method TestSFTP (line 25) | func (suite *FtestSuite) TestSFTP() {
function testSFTPDownload (line 32) | func testSFTPDownload(t *testing.T, hostShareURL, hostNodeAddr, clientJo...
function testSFTPUpload (line 87) | func testSFTPUpload(t *testing.T, hostShareURL, hostNodeAddr, clientJoin...
function testSFTPReadOnly (line 141) | func testSFTPReadOnly(t *testing.T, hostShareURL, hostNodeAddr, clientJo...
function testSFTPDisabled (line 199) | func testSFTPDisabled(t *testing.T, hostShareURL, hostNodeAddr, clientJo...
function testSFTPDirectoryListing (line 234) | func testSFTPDirectoryListing(t *testing.T, hostShareURL, hostNodeAddr, ...
function testSFTPSetstat (line 303) | func testSFTPSetstat(t *testing.T, hostShareURL, hostNodeAddr, clientJoi...
FILE: host/adminclient.go
constant AdminSockExt (line 14) | AdminSockExt = ".sock"
function AdminSocketFile (line 17) | func AdminSocketFile(sessionID string) string {
function AdminClient (line 21) | func AdminClient(socket string) (api.AdminServiceClient, error) {
FILE: host/api/api.pb.go
constant _ (line 18) | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
constant _ (line 20) | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
type Identifier_Type (line 23) | type Identifier_Type
method Enum (line 42) | func (x Identifier_Type) Enum() *Identifier_Type {
method String (line 48) | func (x Identifier_Type) String() string {
method Descriptor (line 52) | func (Identifier_Type) Descriptor() protoreflect.EnumDescriptor {
method Type (line 56) | func (Identifier_Type) Type() protoreflect.EnumType {
method Number (line 60) | func (x Identifier_Type) Number() protoreflect.EnumNumber {
method EnumDescriptor (line 65) | func (Identifier_Type) EnumDescriptor() ([]byte, []int) {
constant Identifier_HOST (line 26) | Identifier_HOST Identifier_Type = 0
constant Identifier_CLIENT (line 27) | Identifier_CLIENT Identifier_Type = 1
type GetSessionRequest (line 69) | type GetSessionRequest struct
method Reset (line 75) | func (x *GetSessionRequest) Reset() {
method String (line 84) | func (x *GetSessionRequest) String() string {
method ProtoMessage (line 88) | func (*GetSessionRequest) ProtoMessage() {}
method ProtoReflect (line 90) | func (x *GetSessionRequest) ProtoReflect() protoreflect.Message {
method Descriptor (line 103) | func (*GetSessionRequest) Descriptor() ([]byte, []int) {
type GetSessionResponse (line 107) | type GetSessionResponse struct
method Reset (line 123) | func (x *GetSessionResponse) Reset() {
method String (line 132) | func (x *GetSessionResponse) String() string {
method ProtoMessage (line 136) | func (*GetSessionResponse) ProtoMessage() {}
method ProtoReflect (line 138) | func (x *GetSessionResponse) ProtoReflect() protoreflect.Message {
method Descriptor (line 151) | func (*GetSessionResponse) Descriptor() ([]byte, []int) {
method GetSessionId (line 155) | func (x *GetSessionResponse) GetSessionId() string {
method GetCommand (line 162) | func (x *GetSessionResponse) GetCommand() []string {
method GetForceCommand (line 169) | func (x *GetSessionResponse) GetForceCommand() []string {
method GetHost (line 176) | func (x *GetSessionResponse) GetHost() string {
method GetNodeAddr (line 183) | func (x *GetSessionResponse) GetNodeAddr() string {
method GetConnectedClients (line 190) | func (x *GetSessionResponse) GetConnectedClients() []*Client {
method GetAuthorizedKeys (line 197) | func (x *GetSessionResponse) GetAuthorizedKeys() []*AuthorizedKey {
method GetSshUser (line 204) | func (x *GetSessionResponse) GetSshUser() string {
method GetSftpDisabled (line 211) | func (x *GetSessionResponse) GetSftpDisabled() bool {
type AuthorizedKey (line 218) | type AuthorizedKey struct
method Reset (line 227) | func (x *AuthorizedKey) Reset() {
method String (line 236) | func (x *AuthorizedKey) String() string {
method ProtoMessage (line 240) | func (*AuthorizedKey) ProtoMessage() {}
method ProtoReflect (line 242) | func (x *AuthorizedKey) ProtoReflect() protoreflect.Message {
method Descriptor (line 255) | func (*AuthorizedKey) Descriptor() ([]byte, []int) {
method GetPublicKeyFingerprints (line 259) | func (x *AuthorizedKey) GetPublicKeyFingerprints() []string {
method GetComment (line 266) | func (x *AuthorizedKey) GetComment() string {
type Client (line 273) | type Client struct
method Reset (line 284) | func (x *Client) Reset() {
method String (line 293) | func (x *Client) String() string {
method ProtoMessage (line 297) | func (*Client) ProtoMessage() {}
method ProtoReflect (line 299) | func (x *Client) ProtoReflect() protoreflect.Message {
method Descriptor (line 312) | func (*Client) Descriptor() ([]byte, []int) {
method GetId (line 316) | func (x *Client) GetId() string {
method GetVersion (line 323) | func (x *Client) GetVersion() string {
method GetAddr (line 330) | func (x *Client) GetAddr() string {
method GetPublicKeyFingerprint (line 337) | func (x *Client) GetPublicKeyFingerprint() string {
type Identifier (line 344) | type Identifier struct
method Reset (line 354) | func (x *Identifier) Reset() {
method String (line 363) | func (x *Identifier) String() string {
method ProtoMessage (line 367) | func (*Identifier) ProtoMessage() {}
method ProtoReflect (line 369) | func (x *Identifier) ProtoReflect() protoreflect.Message {
method Descriptor (line 382) | func (*Identifier) Descriptor() ([]byte, []int) {
method GetId (line 386) | func (x *Identifier) GetId() string {
method GetType (line 393) | func (x *Identifier) GetType() Identifier_Type {
method GetNodeAddr (line 400) | func (x *Identifier) GetNodeAddr() string {
function file_api_proto_rawDescGZIP (line 472) | func file_api_proto_rawDescGZIP() []byte {
function init (line 502) | func init() { file_api_proto_init() }
function file_api_proto_init (line 503) | func file_api_proto_init() {
FILE: host/api/api_grpc.pb.go
constant _ (line 19) | _ = grpc.SupportPackageIsVersion7
type AdminServiceClient (line 24) | type AdminServiceClient interface
type adminServiceClient (line 28) | type adminServiceClient struct
method GetSession (line 36) | func (c *adminServiceClient) GetSession(ctx context.Context, in *GetSe...
function NewAdminServiceClient (line 32) | func NewAdminServiceClient(cc grpc.ClientConnInterface) AdminServiceClie...
type AdminServiceServer (line 48) | type AdminServiceServer interface
type UnimplementedAdminServiceServer (line 53) | type UnimplementedAdminServiceServer struct
method GetSession (line 56) | func (UnimplementedAdminServiceServer) GetSession(context.Context, *Ge...
type UnsafeAdminServiceServer (line 63) | type UnsafeAdminServiceServer interface
function RegisterAdminServiceServer (line 67) | func RegisterAdminServiceServer(s grpc.ServiceRegistrar, srv AdminServic...
function _AdminService_GetSession_Handler (line 71) | func _AdminService_GetSession_Handler(srv interface{}, ctx context.Conte...
FILE: host/authorizedkeys.go
constant codebergKeysUrlFmt (line 19) | codebergKeysUrlFmt = "https://codeberg.org/%s"
constant gitHubKeysUrlFmt (line 20) | gitHubKeysUrlFmt = "https://github.com/%s"
constant gitLabKeysUrlFmt (line 21) | gitLabKeysUrlFmt = "https://gitlab.com/%s"
constant sourceHutKeysUrlFmt (line 22) | sourceHutKeysUrlFmt = "https://meta.sr.ht/~%s"
type AuthorizedKey (line 25) | type AuthorizedKey struct
function AuthorizedKeysFromFile (line 30) | func AuthorizedKeysFromFile(file string) (*AuthorizedKey, error) {
function CodebergUserAuthorizedKeys (line 39) | func CodebergUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, e...
function GitHubUserAuthorizedKeys (line 43) | func GitHubUserAuthorizedKeys(usernames []string, logger *slog.Logger) (...
function GitLabUserAuthorizedKeys (line 69) | func GitLabUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, err...
function SourceHutUserAuthorizedKeys (line 73) | func SourceHutUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, ...
function parseAuthorizedKeys (line 77) | func parseAuthorizedKeys(keysBytes []byte, comment string) (*AuthorizedK...
function githubUserPublicKeys (line 95) | func githubUserPublicKeys(username string, logger *slog.Logger) ([]byte,...
function usersPublicKeys (line 122) | func usersPublicKeys(urlFmt string, usernames []string) ([]*AuthorizedKe...
function userPublicKeys (line 146) | func userPublicKeys(urlFmt string, username string) ([]byte, error) {
FILE: host/host.go
function NewPromptingHostKeyCallback (line 29) | func NewPromptingHostKeyCallback(stdin io.Reader, stdout io.Writer, know...
function NewAutoAcceptingHostKeyCallback (line 38) | func NewAutoAcceptingHostKeyCallback(stdout io.Writer, knownHostsFilenam...
function newHostKeyCallback (line 42) | func newHostKeyCallback(stdin io.Reader, stdout io.Writer, knownHostsFil...
constant markerCert (line 64) | markerCert = "@cert-authority"
constant errKeyMismatch (line 66) | errKeyMismatch = `
constant errNoAuthoritiesHostname (line 78) | errNoAuthoritiesHostname = "ssh: no authorities for hostname"
type hostKeyCallback (line 81) | type hostKeyCallback struct
method checkHostKey (line 89) | func (cb hostKeyCallback) checkHostKey(hostname string, remote net.Add...
method promptForConfirmation (line 116) | func (cb hostKeyCallback) promptForConfirmation(hostname string, remot...
method autoAcceptHostKey (line 148) | func (cb hostKeyCallback) autoAcceptHostKey(hostname string, key ssh.P...
method appendHostLine (line 159) | func (cb hostKeyCallback) appendHostLine(isCert bool, hostname string,...
type Host (line 192) | type Host struct
method Run (line 216) | func (c *Host) Run(ctx context.Context) error {
function keyType (line 400) | func keyType(t string) string {
function createFileIfNotExist (line 404) | func createFileIfNotExist(file string) error {
function toApiAuthorizedKeys (line 425) | func toApiAuthorizedKeys(aks []*AuthorizedKey) []*api.AuthorizedKey {
function displayVersionWarning (line 443) | func displayVersionWarning(out io.Writer, logger *slog.Logger, result *v...
FILE: host/host_test.go
constant testPublicKey (line 18) | testPublicKey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0EWrjdcHcuMfI8bGA...
function Test_hostKeyCallbackKnowHostsFileNotExist (line 21) | func Test_hostKeyCallbackKnowHostsFileNotExist(t *testing.T) {
function Test_hostKeyCallback (line 52) | func Test_hostKeyCallback(t *testing.T) {
function Test_hostKeyCallbackIPv6WithPort (line 86) | func Test_hostKeyCallbackIPv6WithPort(t *testing.T) {
function Test_hostKeyCallbackIPv6WithCertAuthority (line 125) | func Test_hostKeyCallbackIPv6WithCertAuthority(t *testing.T) {
function Test_autoAcceptingHostKeyCallback (line 178) | func Test_autoAcceptingHostKeyCallback(t *testing.T) {
function Test_autoAcceptingHostKeyCallbackWithCertificate (line 206) | func Test_autoAcceptingHostKeyCallbackWithCertificate(t *testing.T) {
function Test_autoAcceptingHostKeyCallbackValidatesKnownKeys (line 244) | func Test_autoAcceptingHostKeyCallbackValidatesKnownKeys(t *testing.T) {
FILE: host/host_unix.go
function setupSignalHandler (line 17) | func setupSignalHandler(g *run.Group, ctx context.Context) {
FILE: host/host_windows.go
function setupSignalHandler (line 18) | func setupSignalHandler(g *run.Group, ctx context.Context) {
FILE: host/internal/adminserver.go
type AdminServer (line 12) | type AdminServer struct
method Serve (line 19) | func (s *AdminServer) Serve(ctx context.Context, sock string) error {
method Shutdown (line 36) | func (s *AdminServer) Shutdown(ctx context.Context) error {
type adminServiceServer (line 47) | type adminServiceServer struct
method GetSession (line 52) | func (s *adminServiceServer) GetSession(ctx context.Context, in *api.G...
FILE: host/internal/client.go
function NewClientRepo (line 10) | func NewClientRepo() *ClientRepo {
type ClientRepo (line 14) | type ClientRepo struct
method Add (line 18) | func (c *ClientRepo) Add(client *api.Client) error {
method Delete (line 27) | func (c *ClientRepo) Delete(clientId string) {
method Get (line 31) | func (c *ClientRepo) Get(clientId string) *api.Client {
method Clients (line 40) | func (c *ClientRepo) Clients() []*api.Client {
FILE: host/internal/command.go
function newCommand (line 16) | func newCommand(
type command (line 38) | type command struct
method Start (line 67) | func (c *command) Start(ctx context.Context) (PTY, error) {
method Run (line 82) | func (c *command) Run() error {
function setupCommand (line 63) | func setupCommand(ctx context.Context, name string, args []string) *exec...
FILE: host/internal/command_test.go
function TestCommand_NonTTY_WithForceFlag (line 19) | func TestCommand_NonTTY_WithForceFlag(t *testing.T) {
function TestCommand_ContextCancellation (line 134) | func TestCommand_ContextCancellation(t *testing.T) {
FILE: host/internal/command_unix.go
method setupTerminalResize (line 16) | func (c *command) setupTerminalResize(g *run.Group, stdin *os.File, ptmx...
FILE: host/internal/command_unix_test.go
function TestCommand_Unix_PTY (line 22) | func TestCommand_Unix_PTY(t *testing.T) {
FILE: host/internal/command_windows.go
method setupTerminalResize (line 17) | func (c *command) setupTerminalResize(g *run.Group, stdin *os.File, ptmx...
FILE: host/internal/command_windows_test.go
function TestCommand_Windows_BasicExecution (line 19) | func TestCommand_Windows_BasicExecution(t *testing.T) {
function TestCommand_Windows_JobObject (line 67) | func TestCommand_Windows_JobObject(t *testing.T) {
function TestCommand_Windows_ConPTY (line 153) | func TestCommand_Windows_ConPTY(t *testing.T) {
FILE: host/internal/event.go
constant errBadFileDescriptor (line 16) | errBadFileDescriptor = "bad file descriptor"
type terminal (line 19) | type terminal struct
type window (line 25) | type window struct
type terminalEventEmitter (line 30) | type terminalEventEmitter struct
method TerminalWindowChanged (line 34) | func (t terminalEventEmitter) TerminalWindowChanged(id string, pty PTY...
method TerminalDetached (line 46) | func (t terminalEventEmitter) TerminalDetached(id string, pty PTY) {
type terminalEventHandler (line 54) | type terminalEventHandler struct
method Handle (line 59) | func (t terminalEventHandler) Handle(ctx context.Context) error {
method handleWindowChanged (line 85) | func (t terminalEventHandler) handleWindowChanged(evt emitter.Event, m...
method handleTerminalDetached (line 110) | func (t terminalEventHandler) handleTerminalDetached(evt emitter.Event...
function resizeWindow (line 134) | func resizeWindow(ptmx PTY, ts map[string]terminal) error {
FILE: host/internal/pty.go
type PTY (line 17) | type PTY interface
FILE: host/internal/pty_unix.go
function startPty (line 14) | func startPty(c *exec.Cmd, stdin *os.File) (PTY, error) {
function ptyError (line 40) | func ptyError(err error) error {
function getPtysize (line 48) | func getPtysize(f *os.File) (h, w int, err error) {
function wrapPty (line 52) | func wrapPty(f *os.File, cmd *exec.Cmd) *pty {
type pty (line 61) | type pty struct
method Setsize (line 67) | func (pty *pty) Setsize(h, w int) error {
method Read (line 78) | func (pty *pty) Read(p []byte) (n int, err error) {
method Close (line 85) | func (pty *pty) Close() error {
method Wait (line 93) | func (pty *pty) Wait() error {
method Kill (line 105) | func (pty *pty) Kill() error {
FILE: host/internal/pty_windows.go
function startPty (line 26) | func startPty(c *exec.Cmd, stdin *os.File) (PTY, error) {
type pty (line 78) | type pty struct
method Setsize (line 88) | func (p *pty) Setsize(h, w int) error {
method Read (line 99) | func (p *pty) Read(data []byte) (n int, err error) {
method Write (line 112) | func (p *pty) Write(data []byte) (n int, err error) {
method Close (line 125) | func (p *pty) Close() error {
method Wait (line 161) | func (p *pty) Wait() error {
method Kill (line 210) | func (p *pty) Kill() error {
function getPtysize (line 150) | func getPtysize(f *os.File) (h, w int, err error) {
function ptyError (line 156) | func ptyError(err error) error {
constant JobObjectExtendedLimitInformation (line 235) | JobObjectExtendedLimitInformation = 9
constant JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE (line 236) | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
type JOBOBJECT_BASIC_LIMIT_INFORMATION (line 239) | type JOBOBJECT_BASIC_LIMIT_INFORMATION struct
type IO_COUNTERS (line 251) | type IO_COUNTERS struct
type JOBOBJECT_EXTENDED_LIMIT_INFORMATION (line 260) | type JOBOBJECT_EXTENDED_LIMIT_INFORMATION struct
function createJobObject (line 272) | func createJobObject(processHandle syscall.Handle) (syscall.Handle, erro...
FILE: host/internal/reversetunnel.go
constant publickeyAuthError (line 21) | publickeyAuthError = "ssh: unable to authenticate, attempted methods [no...
type ReverseTunnel (line 24) | type ReverseTunnel struct
method Close (line 37) | func (c *ReverseTunnel) Close() {
method Listener (line 42) | func (c *ReverseTunnel) Listener() net.Listener {
method Establish (line 46) | func (c *ReverseTunnel) Establish(ctx context.Context) (*server.Create...
method createSession (line 121) | func (c *ReverseTunnel) createSession(user string, hostPublicKeys [][]...
function keepAlive (line 148) | func keepAlive(ctx context.Context, d time.Duration, fn func()) {
function isWSScheme (line 162) | func isWSScheme(scheme string) bool {
type PermissionDeniedError (line 166) | type PermissionDeniedError struct
method Error (line 171) | func (e *PermissionDeniedError) Error() string {
method Unwrap (line 175) | func (e *PermissionDeniedError) Unwrap() error { return e.err }
function sshDialError (line 177) | func sshDialError(host string, err error) error {
FILE: host/internal/server.go
type Server (line 26) | type Server struct
method ServeWithContext (line 48) | func (s *Server) ServeWithContext(ctx context.Context, l net.Listener)...
type publicKeyHandler (line 163) | type publicKeyHandler struct
method HandlePublicKey (line 169) | func (h *publicKeyHandler) HandlePublicKey(ctx gssh.Context, key gssh....
type sessionHandler (line 195) | type sessionHandler struct
method HandleSession (line 209) | func (h *sessionHandler) HandleSession(sess gssh.Session) {
function emitClientJoinEvent (line 336) | func emitClientJoinEvent(eventEmmiter *emitter.Emitter, sessionID string...
function emitClientLeftEvent (line 346) | func emitClientLeftEvent(eventEmmiter *emitter.Emitter, sessionID string) {
function startAttachCmd (line 350) | func startAttachCmd(ctx context.Context, c []string, term string) (PTY, ...
FILE: host/internal/sftp.go
type SFTPSession (line 20) | type SFTPSession struct
method checkPermission (line 82) | func (s *SFTPSession) checkPermission(op hostsftp.Operation, paths ......
method resolvePath (line 109) | func (s *SFTPSession) resolvePath(reqPath string) (string, error) {
method HandleSFTP (line 27) | func (h *sessionHandler) HandleSFTP(sess gssh.Session) {
type sftpFileReader (line 132) | type sftpFileReader struct
method Fileread (line 137) | func (h *sftpFileReader) Fileread(r *sftp.Request) (io.ReaderAt, error) {
type sftpFileWriter (line 154) | type sftpFileWriter struct
method Filewrite (line 159) | func (h *sftpFileWriter) Filewrite(r *sftp.Request) (io.WriterAt, erro...
type sftpFileCmder (line 204) | type sftpFileCmder struct
method Filecmd (line 209) | func (h *sftpFileCmder) Filecmd(r *sftp.Request) error {
type sftpFileLister (line 322) | type sftpFileLister struct
method Filelist (line 327) | func (h *sftpFileLister) Filelist(r *sftp.Request) (sftp.ListerAt, err...
type listerat (line 380) | type listerat
method ListAt (line 382) | func (l listerat) ListAt(ls []fs.FileInfo, offset int64) (int, error) {
type linkInfo (line 395) | type linkInfo struct
method Name (line 399) | func (l linkInfo) Name() string { return l.name }
method Size (line 400) | func (l linkInfo) Size() int64 { return 0 }
method Mode (line 401) | func (l linkInfo) Mode() fs.FileMode { return 0 }
method ModTime (line 402) | func (l linkInfo) ModTime() time.Time { return time.Time{} }
method IsDir (line 403) | func (l linkInfo) IsDir() bool { return false }
method Sys (line 404) | func (l linkInfo) Sys() any { return nil }
FILE: host/internal/sftp_test.go
function TestSFTPSession_resolvePath (line 13) | func TestSFTPSession_resolvePath(t *testing.T) {
function TestListerat (line 137) | func TestListerat(t *testing.T) {
FILE: host/sftp/permission.go
type Operation (line 4) | type Operation
method String (line 19) | func (o Operation) String() string {
constant OpDownload (line 7) | OpDownload Operation = iota
constant OpUpload (line 8) | OpUpload
constant OpDelete (line 9) | OpDelete
constant OpMkdir (line 10) | OpMkdir
constant OpRmdir (line 11) | OpRmdir
constant OpRename (line 12) | OpRename
constant OpSymlink (line 13) | OpSymlink
constant OpLink (line 14) | OpLink
constant OpSetstat (line 15) | OpSetstat
type PermissionResult (line 45) | type PermissionResult
constant PermissionDenied (line 48) | PermissionDenied PermissionResult = iota
constant PermissionAllowed (line 49) | PermissionAllowed
constant PermissionAlwaysAllow (line 50) | PermissionAlwaysAllow
type ClientInfo (line 54) | type ClientInfo struct
type PermissionChecker (line 61) | type PermissionChecker interface
FILE: host/signer.go
constant errCannotDecodeEncryptedPrivateKeys (line 20) | errCannotDecodeEncryptedPrivateKeys = "cannot decode encrypted private k...
type errDescryptingPrivateKey (line 23) | type errDescryptingPrivateKey struct
method Error (line 27) | func (e *errDescryptingPrivateKey) Error() string {
function Signers (line 34) | func Signers(privateKeys []string) ([]ssh.Signer, func(), error) {
function SignersFromFiles (line 53) | func SignersFromFiles(privateKeys []string) ([]ssh.Signer, error) {
function signersFromSSHAgent (line 65) | func signersFromSSHAgent(socket string) ([]ssh.Signer, func(), error) {
function signerFromFile (line 83) | func signerFromFile(file string, promptForPassphrase func(file string) (...
function readPrivateKeyFromFile (line 92) | func readPrivateKeyFromFile(file string, promptForPassphrase func(file s...
function promptForPassphrase (line 128) | func promptForPassphrase(file string) ([]byte, error) {
FILE: host/signer_test.go
constant rsaPrivateKey (line 14) | rsaPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
constant rsaPublicKey (line 66) | rsaPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCt//y3H4heRi1+3bO+...
constant ed25519PriavteKey (line 69) | ed25519PriavteKey = `-----BEGIN OPENSSH PRIVATE KEY-----
constant ed25519PublicKey (line 79) | ed25519PublicKey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA9dIfLyILssYzKI...
function Test_signerFromFile (line 82) | func Test_signerFromFile(t *testing.T) {
FILE: internal/context/logging.go
type contextKey (line 9) | type contextKey
constant loggerKey (line 11) | loggerKey contextKey = "logger"
function WithLogger (line 13) | func WithLogger(ctx context.Context, logger *logging.Logger) context.Con...
function Logger (line 17) | func Logger(ctx context.Context) *logging.Logger {
FILE: internal/e2e/e2e_test.go
constant HostPrivateKeyContent (line 20) | HostPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----
constant ClientPublicKeyContent (line 30) | ClientPublicKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHzlndir8K...
constant ClientPrivateKeyContent (line 33) | ClientPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----
constant uptermPrompt (line 44) | uptermPrompt = "UPTERM_READY>"
type testHarness (line 51) | type testHarness struct
method startHost (line 126) | func (h *testHarness) startHost(extraFlags string) string {
method splitPane (line 145) | func (h *testHarness) splitPane(from *tmux.Pane) *tmux.Pane {
method connectClient (line 156) | func (h *testHarness) connectClient(client *tmux.Pane, sshCmd string) {
method connectClientWithKey (line 172) | func (h *testHarness) connectClientWithKey(client *tmux.Pane, sshCmd, ...
method waitForText (line 187) | func (h *testHarness) waitForText(pane *tmux.Pane, expected string, ti...
method writeFile (line 204) | func (h *testHarness) writeFile(name, content string, perm os.FileMode...
function newTestHarness (line 64) | func newTestHarness(t *testing.T, width int) *testHarness {
function skipIfNoTmux (line 211) | func skipIfNoTmux(t *testing.T) {
function extractSSHCommand (line 218) | func extractSSHCommand(output string) string {
function extractScpUserHost (line 238) | func extractScpUserHost(sshCmd string) (string, string) {
function extractScpPortFlag (line 255) | func extractScpPortFlag(sshCmd string) string {
function TestSync (line 267) | func TestSync(t *testing.T) {
function TestMultipleClients (line 290) | func TestMultipleClients(t *testing.T) {
function TestForceCommand (line 318) | func TestForceCommand(t *testing.T) {
function TestAuthorizedKeys (line 334) | func TestAuthorizedKeys(t *testing.T) {
function TestSessionInfo (line 365) | func TestSessionInfo(t *testing.T) {
FILE: internal/e2e/sftp_test.go
function waitForFile (line 16) | func waitForFile(path string, timeout time.Duration) error {
function TestSFTPDownload (line 28) | func TestSFTPDownload(t *testing.T) {
function TestSFTPUpload (line 61) | func TestSFTPUpload(t *testing.T) {
function TestSFTPDisabled (line 94) | func TestSFTPDisabled(t *testing.T) {
function TestSFTPReadOnly (line 125) | func TestSFTPReadOnly(t *testing.T) {
FILE: internal/logging/logging.go
constant sentryFlushTimeout (line 19) | sentryFlushTimeout = 2 * time.Second
type Logger (line 23) | type Logger struct
method Close (line 29) | func (l *Logger) Close() error {
method With (line 39) | func (l *Logger) With(args ...any) *Logger {
method WithGroup (line 47) | func (l *Logger) WithGroup(name string) *Logger {
type Option (line 55) | type Option
type config (line 57) | type config struct
function New (line 65) | func New(opts ...Option) (*Logger, error) {
function Must (line 90) | func Must(opts ...Option) *Logger {
function Level (line 99) | func Level(level slog.Level) Option {
function Debug (line 107) | func Debug() Option {
function Console (line 112) | func Console() Option {
function File (line 120) | func File(path string) Option {
function Sentry (line 142) | func Sentry(dsn string) Option {
function newSentryHandler (line 158) | func newSentryHandler(dsn string) (slog.Handler, func() error, error) {
FILE: internal/testhelpers/consul.go
constant ConsulHealthCheckTimeout (line 14) | ConsulHealthCheckTimeout = 2 * time.Second
function IsConsulAvailable (line 18) | func IsConsulAvailable() bool {
function ConsulURL (line 51) | func ConsulURL() string {
function ConsulClient (line 60) | func ConsulClient() (*api.Client, error) {
FILE: internal/version/version.go
type BuildInfo (line 41) | type BuildInfo struct
function Parse (line 48) | func Parse(v string) (*version.Version, error) {
function ParseFromSSHVersion (line 54) | func ParseFromSSHVersion(sshVersion string) (*version.Version, error) {
function Current (line 78) | func Current() *version.Version {
function String (line 87) | func String() string {
function GetBuildInfo (line 92) | func GetBuildInfo() BuildInfo {
function PrintVersion (line 101) | func PrintVersion(binaryName string) {
function ServerSSHVersion (line 115) | func ServerSSHVersion() string {
type CompatibilityResult (line 120) | type CompatibilityResult struct
function CheckCompatibility (line 129) | func CheckCompatibility(sshVersion string) *CompatibilityResult {
FILE: internal/version/version_test.go
function TestParseFromSSHVersion (line 10) | func TestParseFromSSHVersion(t *testing.T) {
function TestCheckCompatibility (line 82) | func TestCheckCompatibility(t *testing.T) {
function TestServerSSHVersion (line 153) | func TestServerSSHVersion(t *testing.T) {
function TestCurrent (line 160) | func TestCurrent(t *testing.T) {
function TestCurrentPanic (line 167) | func TestCurrentPanic(t *testing.T) {
FILE: io/query_filter.go
type TerminalQueryFilter (line 19) | type TerminalQueryFilter struct
method Write (line 57) | func (f *TerminalQueryFilter) Write(p []byte) (int, error) {
method processByte (line 78) | func (f *TerminalQueryFilter) processByte(b byte) {
method isCSIQuery (line 243) | func (f *TerminalQueryFilter) isCSIQuery(finalByte byte) bool {
method flushAndReset (line 279) | func (f *TerminalQueryFilter) flushAndReset() {
type queryFilterState (line 27) | type queryFilterState
constant qfStateNormal (line 30) | qfStateNormal queryFilterState = iota
constant qfStateEsc (line 31) | qfStateEsc
constant qfStateCSI (line 32) | qfStateCSI
constant qfStateCSIParam (line 33) | qfStateCSIParam
constant qfStateOSC (line 34) | qfStateOSC
constant qfStateOSCParam (line 35) | qfStateOSCParam
constant qfStateOSCSemi (line 36) | qfStateOSCSemi
constant qfStateOSCQuery (line 37) | qfStateOSCQuery
constant qfStateOSCQueryEsc (line 38) | qfStateOSCQueryEsc
constant qfStateOSCContent (line 39) | qfStateOSCContent
constant qfStateOSCContentEsc (line 40) | qfStateOSCContentEsc
function NewTerminalQueryFilter (line 45) | func NewTerminalQueryFilter(w io.Writer) *TerminalQueryFilter {
FILE: io/query_filter_test.go
function TestTerminalQueryFilter (line 11) | func TestTerminalQueryFilter(t *testing.T) {
function TestTerminalQueryFilter_PreservesNonQueryOSC (line 129) | func TestTerminalQueryFilter_PreservesNonQueryOSC(t *testing.T) {
function TestTerminalQueryFilter_SplitWrites (line 182) | func TestTerminalQueryFilter_SplitWrites(t *testing.T) {
function TestTerminalQueryFilter_TertiaryDeviceAttributes (line 243) | func TestTerminalQueryFilter_TertiaryDeviceAttributes(t *testing.T) {
type errWriter (line 277) | type errWriter struct
method Write (line 281) | func (e *errWriter) Write(p []byte) (int, error) {
function TestTerminalQueryFilter_ErrorHandling (line 285) | func TestTerminalQueryFilter_ErrorHandling(t *testing.T) {
function TestTerminalQueryFilter_ErrorNotReturnedForFilteredContent (line 298) | func TestTerminalQueryFilter_ErrorNotReturnedForFilteredContent(t *testi...
FILE: io/reader.go
function NewContextReader (line 8) | func NewContextReader(ctx context.Context, r io.Reader) io.Reader {
type contextReader (line 15) | type contextReader struct
method Read (line 25) | func (r contextReader) Read(p []byte) (n int, err error) {
type readResult (line 20) | type readResult struct
FILE: io/reader_test.go
function Test_ContextReader (line 13) | func Test_ContextReader(t *testing.T) {
type readFunc (line 95) | type readFunc
method Read (line 97) | func (rf readFunc) Read(p []byte) (n int, err error) { return rf(p) }
FILE: io/writer.go
type buffer (line 8) | type buffer struct
method Append (line 15) | func (c *buffer) Append(p []byte) {
method Size (line 30) | func (c *buffer) Size() int {
method Data (line 37) | func (c *buffer) Data() [][]byte {
function NewMultiWriter (line 45) | func NewMultiWriter(bufferSize int, writers ...io.Writer) *MultiWriter {
type MultiWriter (line 54) | type MultiWriter struct
method Append (line 61) | func (t *MultiWriter) Append(writers ...io.Writer) error {
method Remove (line 81) | func (t *MultiWriter) Remove(writers ...io.Writer) {
method Write (line 95) | func (t *MultiWriter) Write(p []byte) (n int, err error) {
FILE: io/writer_test.go
function Test_MultiWriter (line 11) | func Test_MultiWriter(t *testing.T) {
FILE: memlistener/memlistener.go
constant defaultBufferSize (line 17) | defaultBufferSize = 256 * 1024
type addr (line 20) | type addr struct
method Network (line 22) | func (addr) Network() string { return "mem" }
method String (line 23) | func (addr) String() string { return "mem" }
type errListenerAlreadyExist (line 25) | type errListenerAlreadyExist struct
method Error (line 29) | func (e errListenerAlreadyExist) Error() string {
type errListenerNotFound (line 33) | type errListenerNotFound struct
method Error (line 37) | func (e errListenerNotFound) Error() string {
function New (line 41) | func New() *MemoryListener {
type MemoryListener (line 45) | type MemoryListener struct
method Listen (line 49) | func (l *MemoryListener) Listen(network, address string) (net.Listener...
method ListenMem (line 53) | func (l *MemoryListener) ListenMem(network, address string, sz int) (n...
method Dial (line 77) | func (l *MemoryListener) Dial(network, address string) (net.Conn, erro...
method removeListener (line 98) | func (l *MemoryListener) removeListener(address string) {
type memlistener (line 102) | type memlistener struct
method Close (line 108) | func (m *memlistener) Close() error {
FILE: memlistener/memlistener_test.go
function Test_MemListener_Listen (line 10) | func Test_MemListener_Listen(t *testing.T) {
function Test_MemListener_Dial (line 31) | func Test_MemListener_Dial(t *testing.T) {
function Test_MemListener_RemoveListener (line 43) | func Test_MemListener_RemoveListener(t *testing.T) {
FILE: routing/encoding.go
type Encoder (line 14) | type Encoder interface
type Decoder (line 20) | type Decoder interface
type ModeProvider (line 26) | type ModeProvider interface
type EncodeDecoder (line 32) | type EncodeDecoder interface
function NewEncoder (line 39) | func NewEncoder(mode Mode) Encoder {
function NewDecoder (line 44) | func NewDecoder(mode Mode) Decoder {
function NewEncodeDecoder (line 49) | func NewEncodeDecoder(mode Mode) EncodeDecoder {
type EmbeddedEncodeDecoder (line 61) | type EmbeddedEncodeDecoder struct
method Encode (line 63) | func (e *EmbeddedEncodeDecoder) Encode(sessionID, nodeAddr string) str...
method Decode (line 67) | func (e *EmbeddedEncodeDecoder) Decode(sshUser string) (sessionID, nod...
method Mode (line 81) | func (e *EmbeddedEncodeDecoder) Mode() Mode {
type ConsulEncodeDecoder (line 86) | type ConsulEncodeDecoder struct
method Encode (line 88) | func (c *ConsulEncodeDecoder) Encode(sessionID, nodeAddr string) string {
method Decode (line 92) | func (c *ConsulEncodeDecoder) Decode(sshUser string) (sessionID, nodeA...
method Mode (line 109) | func (c *ConsulEncodeDecoder) Mode() Mode {
FILE: routing/encoding_test.go
type EncodeDecoderTestSuite (line 10) | type EncodeDecoderTestSuite struct
method TestEmbeddedEncodeDecoder (line 14) | func (suite *EncodeDecoderTestSuite) TestEmbeddedEncodeDecoder() {
method TestConsulEncodeDecoder (line 33) | func (suite *EncodeDecoderTestSuite) TestConsulEncodeDecoder() {
method TestEmbeddedDecodeInvalidFormats (line 50) | func (suite *EncodeDecoderTestSuite) TestEmbeddedDecodeInvalidFormats() {
method TestConsulDecodeInvalidFormats (line 70) | func (suite *EncodeDecoderTestSuite) TestConsulDecodeInvalidFormats() {
method TestConsulDecodeBackwardCompatibility (line 78) | func (suite *EncodeDecoderTestSuite) TestConsulDecodeBackwardCompatibi...
function TestEncodeDecoderSuite (line 102) | func TestEncodeDecoderSuite(t *testing.T) {
FILE: routing/modes.go
type Mode (line 4) | type Mode
constant ModeEmbedded (line 8) | ModeEmbedded Mode = "embedded"
constant ModeConsul (line 10) | ModeConsul Mode = "consul"
FILE: server/cert.go
constant certClockSkewTolerance (line 23) | certClockSkewTolerance = 1 * time.Minute
type UserCertChecker (line 25) | type UserCertChecker struct
method Authenticate (line 31) | func (c *UserCertChecker) Authenticate(user string, key ssh.PublicKey)...
function parseAuthRequestFromCert (line 47) | func parseAuthRequestFromCert(principal string, cert *ssh.Certificate) (...
type UserCertSigner (line 81) | type UserCertSigner struct
method SignCert (line 87) | func (g *UserCertSigner) SignCert(signer ssh.Signer) (ssh.Signer, erro...
type HostCertSigner (line 121) | type HostCertSigner struct
method SignCert (line 125) | func (s *HostCertSigner) SignCert(signer ssh.Signer) (ssh.Signer, erro...
FILE: server/metrics.go
type metricServer (line 11) | type metricServer struct
method Shutdown (line 16) | func (m *metricServer) Shutdown(ctx context.Context) error {
method ListenAndServe (line 27) | func (m *metricServer) ListenAndServe(addr string) error {
FILE: server/network.go
function init (line 15) | func init() {
type networkProviders (line 19) | type networkProviders
method Get (line 21) | func (n networkProviders) Get(name string) NetworkProvider {
type NetworkProvider (line 31) | type NetworkProvider interface
type NetworkOptions (line 39) | type NetworkOptions
type SessionDialListener (line 41) | type SessionDialListener interface
type SSHDDialListener (line 46) | type SSHDDialListener interface
type MemoryProvider (line 51) | type MemoryProvider struct
method Name (line 56) | func (p *MemoryProvider) Name() string {
method Opts (line 60) | func (p *MemoryProvider) Opts() string {
method SetOpts (line 64) | func (p *MemoryProvider) SetOpts(opts NetworkOptions) error {
method Session (line 70) | func (p *MemoryProvider) Session() SessionDialListener {
method SSHD (line 74) | func (p *MemoryProvider) SSHD() SSHDDialListener {
type memorySSHDDialListener (line 78) | type memorySSHDDialListener struct
method Listen (line 83) | func (l *memorySSHDDialListener) Listen() (net.Listener, error) {
method Dial (line 87) | func (l *memorySSHDDialListener) Dial() (net.Conn, error) {
type memorySessionDialListener (line 91) | type memorySessionDialListener struct
method Listen (line 95) | func (d *memorySessionDialListener) Listen(sessionID string) (net.List...
method Dial (line 99) | func (d *memorySessionDialListener) Dial(sessionID string) (net.Conn, ...
type UnixProvider (line 103) | type UnixProvider struct
method Opts (line 108) | func (p *UnixProvider) Opts() string {
method SetOpts (line 112) | func (p *UnixProvider) SetOpts(opts NetworkOptions) error {
method Session (line 136) | func (p *UnixProvider) Session() SessionDialListener {
method SSHD (line 140) | func (p *UnixProvider) SSHD() SSHDDialListener {
method Name (line 144) | func (p *UnixProvider) Name() string {
type unixSSHDDialListener (line 148) | type unixSSHDDialListener struct
method Listen (line 152) | func (d *unixSSHDDialListener) Listen() (net.Listener, error) {
method Dial (line 156) | func (d *unixSSHDDialListener) Dial() (net.Conn, error) {
type unixSessionDialListener (line 160) | type unixSessionDialListener struct
method Listen (line 164) | func (d *unixSessionDialListener) Listen(sessionID string) (net.Listen...
method Dial (line 168) | func (d *unixSessionDialListener) Dial(sessionID string) (net.Conn, er...
method socketPath (line 172) | func (d *unixSessionDialListener) socketPath(sessionID string) string {
FILE: server/server.go
constant tcpDialTimeout (line 29) | tcpDialTimeout = 1 * time.Second
type Opt (line 32) | type Opt struct
method Validate (line 51) | func (opt *Opt) Validate() error {
method validateConsulConfig (line 74) | func (opt *Opt) validateConsulConfig() error {
method validateEmbeddedConfig (line 95) | func (opt *Opt) validateEmbeddedConfig() error {
function Start (line 100) | func Start(ctx context.Context, opt Opt, logger *slog.Logger) error {
function parseNetworkOpt (line 280) | func parseNetworkOpt(opts []string) NetworkOptions {
type Server (line 290) | type Server struct
method Shutdown (line 308) | func (s *Server) Shutdown() error {
method ServeWithContext (line 347) | func (s *Server) ServeWithContext(ctx context.Context, sshln net.Liste...
type connDialer (line 453) | type connDialer interface
type sshProxyDialer (line 457) | type sshProxyDialer struct
method Dial (line 462) | func (d sshProxyDialer) Dial(id *api.Identifier) (net.Conn, error) {
type tcpConnDialer (line 474) | type tcpConnDialer struct
method Dial (line 477) | func (d tcpConnDialer) Dial(id *api.Identifier) (net.Conn, error) {
type wsConnDialer (line 481) | type wsConnDialer struct
method Dial (line 484) | func (d wsConnDialer) Dial(id *api.Identifier) (net.Conn, error) {
type sidewayConnDialer (line 495) | type sidewayConnDialer struct
method Dial (line 503) | func (cd sidewayConnDialer) Dial(id *api.Identifier) (net.Conn, error) {
FILE: server/server.pb.go
constant _ (line 18) | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
constant _ (line 20) | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
type CreateSessionRequest (line 23) | type CreateSessionRequest struct
method Reset (line 33) | func (x *CreateSessionRequest) Reset() {
method String (line 42) | func (x *CreateSessionRequest) String() string {
method ProtoMessage (line 46) | func (*CreateSessionRequest) ProtoMessage() {}
method ProtoReflect (line 48) | func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message {
method Descriptor (line 61) | func (*CreateSessionRequest) Descriptor() ([]byte, []int) {
method GetHostUser (line 65) | func (x *CreateSessionRequest) GetHostUser() string {
method GetHostPublicKeys (line 72) | func (x *CreateSessionRequest) GetHostPublicKeys() [][]byte {
method GetClientAuthorizedKeys (line 79) | func (x *CreateSessionRequest) GetClientAuthorizedKeys() [][]byte {
type CreateSessionResponse (line 86) | type CreateSessionResponse struct
method Reset (line 96) | func (x *CreateSessionResponse) Reset() {
method String (line 105) | func (x *CreateSessionResponse) String() string {
method ProtoMessage (line 109) | func (*CreateSessionResponse) ProtoMessage() {}
method ProtoReflect (line 111) | func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message {
method Descriptor (line 124) | func (*CreateSessionResponse) Descriptor() ([]byte, []int) {
method GetSessionID (line 128) | func (x *CreateSessionResponse) GetSessionID() string {
method GetNodeAddr (line 135) | func (x *CreateSessionResponse) GetNodeAddr() string {
method GetSshUser (line 142) | func (x *CreateSessionResponse) GetSshUser() string {
type AuthRequest (line 149) | type AuthRequest struct
method Reset (line 159) | func (x *AuthRequest) Reset() {
method String (line 168) | func (x *AuthRequest) String() string {
method ProtoMessage (line 172) | func (*AuthRequest) ProtoMessage() {}
method ProtoReflect (line 174) | func (x *AuthRequest) ProtoReflect() protoreflect.Message {
method Descriptor (line 187) | func (*AuthRequest) Descriptor() ([]byte, []int) {
method GetClientVersion (line 191) | func (x *AuthRequest) GetClientVersion() string {
method GetRemoteAddr (line 198) | func (x *AuthRequest) GetRemoteAddr() string {
method GetAuthorizedKey (line 205) | func (x *AuthRequest) GetAuthorizedKey() []byte {
function file_server_proto_rawDescGZIP (line 251) | func file_server_proto_rawDescGZIP() []byte {
function init (line 272) | func init() { file_server_proto_init() }
function file_server_proto_init (line 273) | func file_server_proto_init() {
FILE: server/session.go
type ErrSessionNotFound (line 26) | type ErrSessionNotFound struct
method Error (line 30) | func (e *ErrSessionNotFound) Error() string {
constant DefaultSessionTTL (line 35) | DefaultSessionTTL = 30 * time.Minute
constant DefaultConsulTimeout (line 36) | DefaultConsulTimeout = 5 * time.Second
constant DefaultWatchTimeout (line 37) | DefaultWatchTimeout = 10 * time.Minute
constant DefaultMaxRetries (line 38) | DefaultMaxRetries = 3
constant DefaultRetryDelay (line 39) | DefaultRetryDelay = 100 * time.Millisecond
constant DefaultKeyPrefix (line 40) | DefaultKeyPrefix = "uptermd"
constant UnusedNodeAddress (line 41) | UnusedNodeAddress = "localhost"
type Session (line 45) | type Session struct
method MarshalJSON (line 54) | func (s *Session) MarshalJSON() ([]byte, error) {
method UnmarshalJSON (line 83) | func (s *Session) UnmarshalJSON(data []byte) error {
method IsClientKeyAllowed (line 123) | func (s *Session) IsClientKeyAllowed(key ssh.PublicKey) bool {
type SessionStore (line 138) | type SessionStore interface
type sessionCache (line 154) | type sessionCache struct
method Get (line 169) | func (c *sessionCache) Get(sessionID string) (*Session, bool) {
method Has (line 178) | func (c *sessionCache) Has(sessionID string) bool {
method Set (line 187) | func (c *sessionCache) Set(sessionID string, session *Session) {
method Delete (line 196) | func (c *sessionCache) Delete(sessionID string) {
method BatchDelete (line 205) | func (c *sessionCache) BatchDelete(sessionIDs []string) {
method ReplaceAll (line 216) | func (c *sessionCache) ReplaceAll(newSessions map[string]*Session) (ad...
function newSessionCache (line 161) | func newSessionCache(logger *slog.Logger) *sessionCache {
type consulSessionStore (line 249) | type consulSessionStore struct
method Store (line 316) | func (c *consulSessionStore) Store(session *Session) error {
method Get (line 389) | func (c *consulSessionStore) Get(sessionID string) (*Session, error) {
method getFromConsulAndCache (line 408) | func (c *consulSessionStore) getFromConsulAndCache(sessionID string) (...
method Delete (line 462) | func (c *consulSessionStore) Delete(sessionID string) error {
method BatchDelete (line 506) | func (c *consulSessionStore) BatchDelete(sessionIDs []string) error {
method deleteBatch (line 531) | func (c *consulSessionStore) deleteBatch(sessionIDs []string) error {
method List (line 566) | func (c *consulSessionStore) List() ([]*Session, error) {
method NodeName (line 607) | func (c *consulSessionStore) NodeName() string {
method SessionKey (line 612) | func (c *consulSessionStore) SessionKey(sessionID string) string {
method SessionsKey (line 616) | func (c *consulSessionStore) SessionsKey() string {
method KeyPrefix (line 621) | func (c *consulSessionStore) KeyPrefix() string {
method registerNode (line 626) | func (c *consulSessionStore) registerNode() error {
method createConsulLockSession (line 638) | func (c *consulSessionStore) createConsulLockSession(sessionID string)...
method startSessionWatch (line 649) | func (c *consulSessionStore) startSessionWatch(cfg *api.Config) error {
method updateSessionReplica (line 696) | func (c *consulSessionStore) updateSessionReplica(kvPairs api.KVPairs) {
method Close (line 716) | func (c *consulSessionStore) Close() error {
method HasInCache (line 724) | func (c *consulSessionStore) HasInCache(sessionID string) bool {
function newConsulSessionStore (line 261) | func newConsulSessionStore(consulURL *url.URL, ttl time.Duration, logger...
type memorySessionStore (line 729) | type memorySessionStore struct
method Store (line 743) | func (m *memorySessionStore) Store(session *Session) error {
method Get (line 755) | func (m *memorySessionStore) Get(sessionID string) (*Session, error) {
method Delete (line 766) | func (m *memorySessionStore) Delete(sessionID string) error {
method BatchDelete (line 778) | func (m *memorySessionStore) BatchDelete(sessionIDs []string) error {
method List (line 791) | func (m *memorySessionStore) List() ([]*Session, error) {
method Close (line 805) | func (m *memorySessionStore) Close() error {
function newMemorySessionStore (line 736) | func newMemorySessionStore(logger *slog.Logger) *memorySessionStore {
function NewSession (line 810) | func NewSession(sessionID, nodeAddr, hostUser string, hostPublicKeys, cl...
type SessionManager (line 836) | type SessionManager struct
method CreateSession (line 939) | func (sm *SessionManager) CreateSession(session *Session) (string, err...
method GetSession (line 949) | func (sm *SessionManager) GetSession(sessionID string) (*Session, erro...
method DeleteSession (line 954) | func (sm *SessionManager) DeleteSession(sessionID string) error {
method shouldValidateSessionExistence (line 964) | func (sm *SessionManager) shouldValidateSessionExistence() bool {
method ResolveSSHUser (line 973) | func (sm *SessionManager) ResolveSSHUser(sshUser string) (sessionID, n...
method GetEncodeDecoder (line 994) | func (sm *SessionManager) GetEncodeDecoder() routing.EncodeDecoder {
method GetRoutingMode (line 999) | func (sm *SessionManager) GetRoutingMode() routing.Mode {
method GetStore (line 1004) | func (sm *SessionManager) GetStore() SessionStore {
method Shutdown (line 1009) | func (sm *SessionManager) Shutdown(nodeAddr string) error {
type SessionManagerConfig (line 842) | type SessionManagerConfig struct
type SessionManagerOption (line 850) | type SessionManagerOption
function WithSessionManagerLogger (line 853) | func WithSessionManagerLogger(logger *slog.Logger) SessionManagerOption {
function WithSessionManagerConsulURL (line 860) | func WithSessionManagerConsulURL(consulURL *url.URL) SessionManagerOption {
function WithSessionManagerConsulTTL (line 867) | func WithSessionManagerConsulTTL(ttl time.Duration) SessionManagerOption {
function NewSessionManager (line 891) | func NewSessionManager(mode routing.Mode, opts ...SessionManagerOption) ...
function newSessionManagerWithStore (line 914) | func newSessionManagerWithStore(store SessionStore, encodeDecoder routin...
function newEmbeddedSessionManager (line 922) | func newEmbeddedSessionManager(logger *slog.Logger) *SessionManager {
function newConsulSessionManager (line 929) | func newConsulSessionManager(consulURL *url.URL, ttl time.Duration, logg...
FILE: server/session_test.go
type EmbeddedSessionManagerTestSuite (line 39) | type EmbeddedSessionManagerTestSuite struct
method SetupTest (line 45) | func (suite *EmbeddedSessionManagerTestSuite) SetupTest() {
method TestCreateAndResolveSession (line 50) | func (suite *EmbeddedSessionManagerTestSuite) TestCreateAndResolveSess...
method TestResolveSSHUser_DoesNotValidateExistence (line 77) | func (suite *EmbeddedSessionManagerTestSuite) TestResolveSSHUser_DoesN...
method TestResolveSSHUser_InvalidFormats (line 94) | func (suite *EmbeddedSessionManagerTestSuite) TestResolveSSHUser_Inval...
method TestRoutingMode (line 112) | func (suite *EmbeddedSessionManagerTestSuite) TestRoutingMode() {
method TestDeleteSession (line 116) | func (suite *EmbeddedSessionManagerTestSuite) TestDeleteSession() {
type ConsulSessionManagerTestSuite (line 141) | type ConsulSessionManagerTestSuite struct
method SetupSuite (line 147) | func (suite *ConsulSessionManagerTestSuite) SetupSuite() {
method TearDownSuite (line 166) | func (suite *ConsulSessionManagerTestSuite) TearDownSuite() {
method TestCreateAndResolveSession (line 176) | func (suite *ConsulSessionManagerTestSuite) TestCreateAndResolveSessio...
method TestResolveSSHUser_ValidatesExistence (line 208) | func (suite *ConsulSessionManagerTestSuite) TestResolveSSHUser_Validat...
method TestRoutingMode (line 236) | func (suite *ConsulSessionManagerTestSuite) TestRoutingMode() {
type MemoryStoreTestSuite (line 245) | type MemoryStoreTestSuite struct
method SetupTest (line 251) | func (suite *MemoryStoreTestSuite) SetupTest() {
method TestStoreOperations (line 256) | func (suite *MemoryStoreTestSuite) TestStoreOperations() {
method TestBatchOperations (line 299) | func (suite *MemoryStoreTestSuite) TestBatchOperations() {
method TestClose (line 329) | func (suite *MemoryStoreTestSuite) TestClose() {
type ConsulStoreTestSuite (line 336) | type ConsulStoreTestSuite struct
method SetupSuite (line 343) | func (suite *ConsulStoreTestSuite) SetupSuite() {
method TearDownSuite (line 367) | func (suite *ConsulStoreTestSuite) TearDownSuite() {
method TestBasicStoreOperations (line 381) | func (suite *ConsulStoreTestSuite) TestBasicStoreOperations() {
method TestReplicationViaCacheAndWatch (line 430) | func (suite *ConsulStoreTestSuite) TestReplicationViaCacheAndWatch() {
method TestReplicationHandlesDeletion (line 460) | func (suite *ConsulStoreTestSuite) TestReplicationHandlesDeletion() {
method TestSessionNotFoundNoRetry (line 485) | func (suite *ConsulStoreTestSuite) TestSessionNotFoundNoRetry() {
method TestBatchOperations (line 500) | func (suite *ConsulStoreTestSuite) TestBatchOperations() {
method waitForSessionInCache (line 544) | func (suite *ConsulStoreTestSuite) waitForSessionInCache(sessionID str...
method waitForSessionRemovedFromCache (line 551) | func (suite *ConsulStoreTestSuite) waitForSessionRemovedFromCache(sess...
function TestEmbeddedSessionManagerTestSuite (line 562) | func TestEmbeddedSessionManagerTestSuite(t *testing.T) {
function TestConsulSessionManagerTestSuite (line 566) | func TestConsulSessionManagerTestSuite(t *testing.T) {
function TestMemoryStoreTestSuite (line 570) | func TestMemoryStoreTestSuite(t *testing.T) {
function TestConsulStoreTestSuite (line 574) | func TestConsulStoreTestSuite(t *testing.T) {
FILE: server/sshd.go
type ServerInfo (line 23) | type ServerInfo struct
type sshd (line 27) | type sshd struct
method Shutdown (line 38) | func (s *sshd) Shutdown() error {
method Serve (line 52) | func (s *sshd) Serve(ln net.Listener) error {
method createSessionHandler (line 106) | func (s *sshd) createSessionHandler(ctx ssh.Context, srv *ssh.Server, ...
FILE: server/sshd_test.go
constant TestPublicKeyContent (line 18) | TestPublicKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0EWrjdcHc...
constant TestPrivateKeyContent (line 19) | TestPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----
function Test_sshd_DisallowSession (line 28) | func Test_sshd_DisallowSession(t *testing.T) {
FILE: server/sshhandler.go
constant forwardedStreamlocalChannelType (line 18) | forwardedStreamlocalChannelType = "forwarded-streamlocal@openssh.com"
constant streamlocalForwardChannelType (line 19) | streamlocalForwardChannelType = "streamlocal-forward@openssh.com"
constant cancelStreamlocalForwardChannelType (line 20) | cancelStreamlocalForwardChannelType = "cancel-streamlocal-forward@openss...
type streamlocalChannelForwardMsg (line 23) | type streamlocalChannelForwardMsg struct
type forwardedStreamlocalPayload (line 27) | type forwardedStreamlocalPayload struct
function isExpectedShutdownError (line 33) | func isExpectedShutdownError(err error) bool {
function newStreamlocalForwardHandler (line 66) | func newStreamlocalForwardHandler(
type streamlocalForwardHandler (line 79) | type streamlocalForwardHandler struct
method listen (line 87) | func (h *streamlocalForwardHandler) listen(ctx ssh.Context, ln net.Lis...
method handleConnection (line 100) | func (h *streamlocalForwardHandler) handleConnection(ctx ssh.Context, ...
method Handler (line 169) | func (h *streamlocalForwardHandler) Handler(ctx ssh.Context, srv *ssh....
method trackListener (line 244) | func (h *streamlocalForwardHandler) trackListener(sessionID string, ln...
method closeListener (line 250) | func (h *streamlocalForwardHandler) closeListener(sessionID string) {
FILE: server/sshhandler_test.go
type SSHHandlerTestSuite (line 15) | type SSHHandlerTestSuite struct
method TestIsExpectedShutdownError (line 23) | func (s *SSHHandlerTestSuite) TestIsExpectedShutdownError() {
method TestIsExpectedShutdownError_EdgeCases (line 100) | func (s *SSHHandlerTestSuite) TestIsExpectedShutdownError_EdgeCases() {
method TestIsExpectedShutdownError_WrappedErrors (line 122) | func (s *SSHHandlerTestSuite) TestIsExpectedShutdownError_WrappedError...
function TestSSHHandlerTestSuite (line 19) | func TestSSHHandlerTestSuite(t *testing.T) {
FILE: server/sshproxy.go
type sshProxy (line 17) | type sshProxy struct
method Shutdown (line 31) | func (r *sshProxy) Shutdown() error {
method Serve (line 42) | func (r *sshProxy) Serve(ln net.Listener) error {
type authPiper (line 69) | type authPiper struct
method checkAuthorizedKeys (line 80) | func (a authPiper) checkAuthorizedKeys(conn ssh.ConnMetadata, pk ssh.P...
method PublicKeyCallback (line 141) | func (a authPiper) PublicKeyCallback(conn ssh.ConnMetadata, pk ssh.Pub...
method dialUpstream (line 222) | func (a *authPiper) dialUpstream(conn ssh.ConnMetadata) (net.Conn, err...
method newUserCertSigners (line 258) | func (a authPiper) newUserCertSigners(conn ssh.ConnMetadata, auth *Aut...
method hostSession (line 280) | func (a *authPiper) hostSession(conn ssh.ConnMetadata) (*Session, erro...
function publicKeyFingerprint (line 105) | func publicKeyFingerprint(pk ssh.PublicKey) string {
function loadAuthorizedKeys (line 116) | func loadAuthorizedKeys(paths []string) (map[string]struct{}, error) {
FILE: server/sshproxy_test.go
constant HostPublicKeyContent (line 24) | HostPublicKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOA+rMcwWFP...
constant HostPrivateKeyContent (line 25) | HostPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY-----
function Test_sshProxy_dialUpstream (line 34) | func Test_sshProxy_dialUpstream(t *testing.T) {
function testCertSigner (line 151) | func testCertSigner(user string, signer ssh.Signer) (ssh.Signer, error) {
type fakeConnMetadata (line 169) | type fakeConnMetadata struct
method User (line 173) | func (f *fakeConnMetadata) User() string { return "" }
method SessionID (line 174) | func (f *fakeConnMetadata) SessionID() []byte { return nil }
method ClientVersion (line 175) | func (f *fakeConnMetadata) ClientVersion() []byte { return []byte(f.cl...
method ServerVersion (line 176) | func (f *fakeConnMetadata) ServerVersion() []byte { return nil }
method RemoteAddr (line 177) | func (f *fakeConnMetadata) RemoteAddr() net.Addr { return nil }
method LocalAddr (line 178) | func (f *fakeConnMetadata) LocalAddr() net.Addr { return nil }
function writeKeyFile (line 180) | func writeKeyFile(t *testing.T, content string) string {
function Test_loadAuthorizedKeys (line 187) | func Test_loadAuthorizedKeys(t *testing.T) {
function Test_authPiper_checkAuthorizedKeys (line 232) | func Test_authPiper_checkAuthorizedKeys(t *testing.T) {
FILE: server/sshrouting.go
type SSHRouting (line 24) | type SSHRouting struct
method Serve (line 54) | func (p *SSHRouting) Serve(ln net.Listener) error {
method Shutdown (line 176) | func (p *SSHRouting) Shutdown() error {
method getDoneChan (line 185) | func (p *SSHRouting) getDoneChan() <-chan struct{} {
method getDoneChanLocked (line 192) | func (p *SSHRouting) getDoneChanLocked() chan struct{} {
method closeDoneChanLocked (line 200) | func (p *SSHRouting) closeDoneChanLocked() {
method closeListenersLocked (line 212) | func (p *SSHRouting) closeListenersLocked() error {
type routingInstruments (line 36) | type routingInstruments struct
function newSSHRoutingInstruments (line 44) | func newSSHRoutingInstruments(p provider.Provider) *routingInstruments {
FILE: server/wsproxy.go
type webSocketProxy (line 22) | type webSocketProxy struct
method Serve (line 54) | func (s *webSocketProxy) Serve(ln net.Listener) error {
method Shutdown (line 68) | func (s *webSocketProxy) Shutdown() error {
function webHandler (line 31) | func webHandler(h http.Handler) http.Handler {
type wsHandler (line 88) | type wsHandler struct
method ServeHTTP (line 97) | func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
method httpError (line 183) | func (h *wsHandler) httpError(w http.ResponseWriter, err error) {
method wsError (line 189) | func (h *wsHandler) wsError(ws *websocket.Conn, err error, msg string) {
FILE: server/wsproxy_test.go
type testSshdDialListener (line 17) | type testSshdDialListener struct
method Dial (line 21) | func (l *testSshdDialListener) Dial() (net.Conn, error) {
method Listen (line 25) | func (l *testSshdDialListener) Listen() (net.Listener, error) {
type testSessionDialListener (line 29) | type testSessionDialListener struct
method Dial (line 33) | func (l *testSessionDialListener) Dial(id string) (net.Conn, error) {
method Listen (line 37) | func (l *testSessionDialListener) Listen(id string) (net.Listener, err...
function Test_WebSocketProxy_Host (line 41) | func Test_WebSocketProxy_Host(t *testing.T) {
function scan (line 105) | func scan(s *bufio.Scanner) string {
FILE: upterm/const.go
constant HostSSHClientVersion (line 5) | HostSSHClientVersion = "SSH-2.0-upterm-host-client"
constant HostSSHServerVersion (line 6) | HostSSHServerVersion = "SSH-2.0-upterm-host-server"
constant HostAdminSocketEnvVar (line 7) | HostAdminSocketEnvVar = "UPTERM_ADMIN_SOCKET"
constant ClientSSHClientVersion (line 10) | ClientSSHClientVersion = "SSH-2.0-upterm-client-client"
constant ServerSSHServerVersion (line 13) | ServerSSHServerVersion = "SSH-2.0-uptermd"
constant ServerServerInfoRequestType (line 14) | ServerServerInfoRequestType = "upterm-server-info@upterm.dev"
constant ServerCreateSessionRequestType (line 15) | ServerCreateSessionRequestType = "upterm-create-session@upterm.dev"
constant HeaderUptermClientVersion (line 18) | HeaderUptermClientVersion = "Upterm-Client-Version"
constant OpenSSHKeepAliveRequestType (line 21) | OpenSSHKeepAliveRequestType = "keepalive@openssh.com"
constant SSHCertExtension (line 23) | SSHCertExtension = "upterm-auth-request"
constant EventClientJoined (line 25) | EventClientJoined = "client-joined"
constant EventClientLeft (line 26) | EventClientLeft = "client-left"
constant EventTerminalWindowChanged (line 27) | EventTerminalWindowChanged = "terminal-window-changed"
constant EventTerminalDetached (line 28) | EventTerminalDetached = "terminal-detached"
FILE: utils/testing.go
function WaitForServer (line 11) | func WaitForServer(ctx context.Context, addr string) error {
FILE: utils/utils.go
constant logFile (line 19) | logFile = "upterm.log"
constant configFile (line 20) | configFile = "config.yaml"
constant appName (line 21) | appName = "upterm"
type envGetter (line 26) | type envGetter
function xdgDirWithFallbackEnv (line 39) | func xdgDirWithFallbackEnv(envVar, xdgPath string, getenv envGetter) str...
function xdgDirWithFallback (line 67) | func xdgDirWithFallback(envVar, xdgPath string) string {
function UptermRuntimeDir (line 84) | func UptermRuntimeDir() string {
function UptermStateDir (line 101) | func UptermStateDir() string {
function UptermLogFilePath (line 116) | func UptermLogFilePath() string {
function UptermConfigDir (line 133) | func UptermConfigDir() string {
function UptermConfigFilePath (line 148) | func UptermConfigFilePath() string {
function CreateUptermRuntimeDir (line 154) | func CreateUptermRuntimeDir() (string, error) {
function DefaultLocalhost (line 162) | func DefaultLocalhost(defaultPort string) string {
function CreateSigners (line 171) | func CreateSigners(privateKeys [][]byte) ([]ssh.Signer, error) {
function ReadFiles (line 202) | func ReadFiles(paths []string) ([][]byte, error) {
function GenerateSessionID (line 217) | func GenerateSessionID() string {
function FingerprintSHA256 (line 221) | func FingerprintSHA256(key ssh.PublicKey) string {
function KeysEqual (line 227) | func KeysEqual(pk1 ssh.PublicKey, pk2 ssh.PublicKey) bool {
function publicKey (line 231) | func publicKey(pk ssh.PublicKey) ssh.PublicKey {
function ShortenHomePath (line 243) | func ShortenHomePath(path string) string {
FILE: utils/utils_test.go
function TestShortenHomePath (line 12) | func TestShortenHomePath(t *testing.T) {
function TestXDGDirWithFallback (line 56) | func TestXDGDirWithFallback(t *testing.T) {
FILE: ws/client.go
function NewSSHClient (line 18) | func NewSSHClient(u *url.URL, config *ssh.ClientConfig, isUptermClient b...
function NewWSConn (line 34) | func NewWSConn(u *url.URL, isUptermClient bool) (net.Conn, error) {
function WrapWSConn (line 49) | func WrapWSConn(ws *websocket.Conn) net.Conn {
function webSocketDialHeader (line 53) | func webSocketDialHeader(sessionID, encodedNodeAddr string, isClient boo...
Condensed preview — 172 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (742K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 44,
"preview": "github: owenthereal\nopen_collective: upterm\n"
},
{
"path": ".github/dependabot.yml",
"chars": 207,
"preview": "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n directory: \"/\"\n schedule:\n interval: \"weekly\"\n "
},
{
"path": ".github/workflows/build-and-release.yaml",
"chars": 1779,
"preview": "name: Build and Release\n\non:\n workflow_call:\n inputs:\n snapshot:\n description: 'Build snapshot (no publi"
},
{
"path": ".github/workflows/build.yaml",
"chars": 3734,
"preview": "name: Build\non:\n push:\n branches:\n - master\n pull_request:\npermissions:\n contents: write\n packages: write\njo"
},
{
"path": ".github/workflows/codeql-analysis.yml",
"chars": 2323,
"preview": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# Y"
},
{
"path": ".github/workflows/e2e.yaml",
"chars": 982,
"preview": "name: E2E Tests\n\non:\n workflow_dispatch:\n inputs:\n uptermd_url:\n description: 'Uptermd server URL (e.g.,"
},
{
"path": ".github/workflows/release.yaml",
"chars": 1201,
"preview": "name: Release\non:\n push:\n tags:\n - 'v*'\npermissions:\n contents: write\n packages: write\njobs:\n build-and-rele"
},
{
"path": ".gitignore",
"chars": 99,
"preview": "build\nc.out\nrelease\n.terraform\n*.tfstate\n*.tfstate.backup\ndist\nbin\nCLAUDE.md\n.claude/\n*.exe\n*.exe~\n"
},
{
"path": ".goreleaser.yml",
"chars": 4796,
"preview": "version: 2\nbuilds:\n - id: upterm\n env:\n - CGO_ENABLED=0\n goos:\n - linux\n - darwin\n - windows\n"
},
{
"path": "CONTRIBUTING.md",
"chars": 4217,
"preview": "# Contributing\n\nWhen contributing to this repository, please first discuss the change you wish to make via issue,\nemail,"
},
{
"path": "Dockerfile.uptermd",
"chars": 1226,
"preview": "# syntax=docker/dockerfile:1\n\n# Build stage - builds from source (used by Fly deployment)\nFROM golang:latest AS builder\n"
},
{
"path": "LICENSE",
"chars": 11357,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "Makefile",
"chars": 1853,
"preview": "SHELL=/bin/bash -o pipefail\n\nBIN_DIR ?= $(CURDIR)/bin\nexport PATH := $(BIN_DIR):$(PATH)\n\n.PHONY: tools\ntools:\n\trm -rf $("
},
{
"path": "Procfile",
"chars": 126,
"preview": "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 --networ"
},
{
"path": "README.md",
"chars": 16391,
"preview": "# Upterm\n\n[Upterm](https://github.com/owenthereal/upterm) is an open-source tool enabling developers to share terminal s"
},
{
"path": "app.json",
"chars": 382,
"preview": "{\n \"name\": \"Upterm\",\n \"keywords\": [\n \"golang\",\n \"terminal\",\n \"upterm\",\n \"uptermd\"\n "
},
{
"path": "charts/uptermd/.helmignore",
"chars": 349,
"preview": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation"
},
{
"path": "charts/uptermd/Chart.yaml",
"chars": 303,
"preview": "apiVersion: v2\nname: uptermd\ndescription: Secure Terminal Sharing\ntype: application\nversion: 0.2.0\nappVersion: 0.14.3\nho"
},
{
"path": "charts/uptermd/templates/NOTES.txt",
"chars": 1213,
"preview": "Host a terminal session by running these commands:\n{{- if contains \"NodePort\" .Values.service.type }}\n export NODE_IP=$"
},
{
"path": "charts/uptermd/templates/_helpers.tpl",
"chars": 1810,
"preview": "{{/* vim: set filetype=mustache: */}}\n{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"upterm.name\" -}}\n{{- default ."
},
{
"path": "charts/uptermd/templates/configmap.yaml",
"chars": 307,
"preview": "{{- if gt (len .Values.authorized_keys) 0 }}\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ include \"upterm.fullnam"
},
{
"path": "charts/uptermd/templates/deployment.yaml",
"chars": 3576,
"preview": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: {{ include \"upterm.fullname\" . }}\n labels:\n {{- include \"upte"
},
{
"path": "charts/uptermd/templates/hpa.yaml",
"chars": 980,
"preview": "{{- if .Values.autoscaling.enabled }}\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n name: {{ incl"
},
{
"path": "charts/uptermd/templates/ingress.yaml",
"chars": 1107,
"preview": "{{- if .Values.websocket.enabled }}\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n name: {{ include \"upterm."
},
{
"path": "charts/uptermd/templates/issuer.yaml",
"chars": 712,
"preview": "{{- if .Values.websocket.enabled }}\napiVersion: cert-manager.io/v1\nkind: Issuer\nmetadata:\n name: {{ include \"upterm.ful"
},
{
"path": "charts/uptermd/templates/secret.yaml",
"chars": 243,
"preview": "apiVersion: v1\nkind: Secret\nmetadata:\n name: {{ include \"upterm.fullname\" . }}\n labels:\n {{- include \"upterm.labels"
},
{
"path": "charts/uptermd/templates/service.yaml",
"chars": 560,
"preview": "apiVersion: v1\nkind: Service\nmetadata:\n name: {{ include \"upterm.fullname\" . }}\n labels:\n {{- include \"upterm.label"
},
{
"path": "charts/uptermd/templates/serviceaccount.yaml",
"chars": 318,
"preview": "{{- if .Values.serviceAccount.create -}}\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: {{ include \"upterm.servic"
},
{
"path": "charts/uptermd/templates/tests/test-connection.yaml",
"chars": 384,
"preview": "apiVersion: v1\nkind: Pod\nmetadata:\n name: \"{{ include \"upterm.fullname\" . }}-test-connection\"\n labels:\n {{- include"
},
{
"path": "charts/uptermd/values.yaml",
"chars": 1855,
"preview": "# Default values for upterm.\n# This is a YAML-formatted file.\n# Declare variables to be passed into your templates.\n\nrep"
},
{
"path": "cmd/gendoc/main.go",
"chars": 1403,
"preview": "package main\n\nimport (\n\t\"os\"\n\n\t\"github.com/owenthereal/upterm/cmd/upterm/command\"\n\t\"github.com/owenthereal/upterm/intern"
},
{
"path": "cmd/upterm/command/config.go",
"chars": 6808,
"preview": "package command\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"runtime\"\n\n\t\"github.com/owenthereal/upterm/utils\"\n\t\"github.com/spf13/"
},
{
"path": "cmd/upterm/command/host.go",
"chars": 14021,
"preview": "package command\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\ttea"
},
{
"path": "cmd/upterm/command/host_test.go",
"chars": 2607,
"preview": "package command\n\nimport (\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test"
},
{
"path": "cmd/upterm/command/host_unix.go",
"chars": 235,
"preview": "//go:build !windows\n\npackage command\n\nimport (\n\t\"os\"\n)\n\n// getDefaultShell returns the default shell on Unix systems\nfun"
},
{
"path": "cmd/upterm/command/host_windows.go",
"chars": 621,
"preview": "//go:build windows\n\npackage command\n\nimport (\n\t\"os/exec\"\n)\n\n// getDefaultShell returns the default shell on Windows\n// P"
},
{
"path": "cmd/upterm/command/internal/tui/host_session.go",
"chars": 3509,
"preview": "package tui\n\nimport (\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\n// HostSessionConfirmResult represents th"
},
{
"path": "cmd/upterm/command/internal/tui/session_detail.go",
"chars": 5062,
"preview": "package tui\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipg"
},
{
"path": "cmd/upterm/command/internal/tui/session_detail_test.go",
"chars": 2964,
"preview": "package tui\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require"
},
{
"path": "cmd/upterm/command/internal/tui/session_list.go",
"chars": 4346,
"preview": "package tui\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/bubbles/table\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"gith"
},
{
"path": "cmd/upterm/command/internal/tui/styles.go",
"chars": 1101,
"preview": "package tui\n\nimport (\n\t\"os\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\n// renderer is bound to stdout for consistent style"
},
{
"path": "cmd/upterm/command/privacy.go",
"chars": 1817,
"preview": "package command\n\nimport \"os\"\n\nvar (\n\tflagHideClientIP bool\n)\n\n// shouldHideClientIP determines if client IP addresses sh"
},
{
"path": "cmd/upterm/command/proxy.go",
"chars": 1446,
"preview": "package command\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/url\"\n\t\"os\"\n\n\t\"github.com/oklog/run\"\n\tuio \"github.com/owenthereal"
},
{
"path": "cmd/upterm/command/root.go",
"chars": 5617,
"preview": "package command\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\tuptermctx \"github.com/owenthereal/upterm/internal/context\"\n\t\"github."
},
{
"path": "cmd/upterm/command/session.go",
"chars": 10931,
"preview": "package command\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"text/"
},
{
"path": "cmd/upterm/command/sftp_permission.go",
"chars": 5906,
"preview": "package command\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/ncruces/zenity\"\n\t\"github.com/owenthereal/upterm/host/s"
},
{
"path": "cmd/upterm/command/upgrade.go",
"chars": 3808,
"preview": "package command\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\tggh \"github.com/google/go-g"
},
{
"path": "cmd/upterm/command/version.go",
"chars": 334,
"preview": "package command\n\nimport (\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc versionCmd"
},
{
"path": "cmd/upterm/main.go",
"chars": 386,
"preview": "package main\n\nimport (\n\t\"errors\"\n\t\"log/slog\"\n\t\"os\"\n\n\t\"github.com/owenthereal/upterm/cmd/upterm/command\"\n)\n\nfunc main() {"
},
{
"path": "cmd/uptermd/command/root.go",
"chars": 3980,
"preview": "package command\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\tuptermctx \"github.com/owenthereal/upterm/internal/context\"\n\t\"github."
},
{
"path": "cmd/uptermd/command/version.go",
"chars": 335,
"preview": "package command\n\nimport (\n\t\"github.com/owenthereal/upterm/internal/version\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc versionCmd"
},
{
"path": "cmd/uptermd/main.go",
"chars": 235,
"preview": "package main\n\nimport (\n\t\"log/slog\"\n\t\"os\"\n\n\t\"github.com/owenthereal/upterm/cmd/uptermd/command\"\n)\n\nfunc main() {\n\tif err "
},
{
"path": "cmd/uptermd-fly/main.go",
"chars": 1444,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\n\t\"github.com/owenthereal/upterm/cmd/uptermd/command\"\n)\n\nfunc main() {\n\t"
},
{
"path": "docs/upterm.md",
"chars": 2129,
"preview": "## upterm\n\nInstant Terminal Sharing\n\n### Synopsis\n\nUpterm is an open-source solution for sharing terminal sessions insta"
},
{
"path": "docs/upterm_config.md",
"chars": 901,
"preview": "## upterm config\n\nManage upterm configuration\n\n### Synopsis\n\nManage upterm configuration file.\n\nConfig file: /home/user/"
},
{
"path": "docs/upterm_config_edit.md",
"chars": 923,
"preview": "## upterm config edit\n\nEdit the config file\n\n### Synopsis\n\nEdit the config file in your default editor.\n\nConfig file: /h"
},
{
"path": "docs/upterm_config_path.md",
"chars": 743,
"preview": "## upterm config path\n\nShow the path to the config file\n\n### Synopsis\n\nShow the path to the config file.\n\nConfig file: /"
},
{
"path": "docs/upterm_config_view.md",
"chars": 832,
"preview": "## upterm config view\n\nView the config file contents\n\n### Synopsis\n\nView the config file contents.\n\nConfig file: /home/u"
},
{
"path": "docs/upterm_host.md",
"chars": 4012,
"preview": "## upterm host\n\nHost a terminal session\n\n### Synopsis\n\nHost a terminal session via a reverse SSH tunnel to the Upterm se"
},
{
"path": "docs/upterm_proxy.md",
"chars": 798,
"preview": "## upterm proxy\n\nProxy a terminal session via WebSocket\n\n### Synopsis\n\nProxy a terminal session via WebSocket, to be use"
},
{
"path": "docs/upterm_session.md",
"chars": 628,
"preview": "## upterm session\n\nDisplay and manage terminal sessions\n\n### Options\n\n```\n -h, --help help for session\n```\n\n### Optio"
},
{
"path": "docs/upterm_session_current.md",
"chars": 1674,
"preview": "## upterm session current\n\nDisplay the current terminal session\n\n### Synopsis\n\nDisplay the current terminal session.\n\nBy"
},
{
"path": "docs/upterm_session_info.md",
"chars": 676,
"preview": "## upterm session info\n\nDisplay terminal session by name\n\n### Synopsis\n\nDisplay terminal session by name.\n\n```\nupterm se"
},
{
"path": "docs/upterm_session_list.md",
"chars": 727,
"preview": "## upterm session list\n\nList shared sessions\n\n### Synopsis\n\nList shared sessions.\n\nSockets are stored in: /run/user/1000"
},
{
"path": "docs/upterm_upgrade.md",
"chars": 523,
"preview": "## upterm upgrade\n\nUpgrade the CLI\n\n```\nupterm upgrade [flags]\n```\n\n### Examples\n\n```\n # Upgrade to the latest version:"
},
{
"path": "docs/upterm_version.md",
"chars": 386,
"preview": "## upterm version\n\nShow version\n\n```\nupterm version [flags]\n```\n\n### Options\n\n```\n -h, --help help for version\n```\n\n#"
},
{
"path": "etc/completion/upterm.bash_completion.sh",
"chars": 22239,
"preview": "# bash completion for upterm -*- shell-script -*-\n\n__upterm_debug()\n{\n if [[ -n ${BASH_"
},
{
"path": "etc/completion/upterm.zsh_completion",
"chars": 7748,
"preview": "#compdef upterm\ncompdef _upterm upterm\n\n# zsh completion for upterm -*- shell-script -*-\n\n"
},
{
"path": "etc/man/man1/upterm-config-edit.1",
"chars": 991,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-config-edit - Edit the config file\n\n"
},
{
"path": "etc/man/man1/upterm-config-path.1",
"chars": 807,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-config-path - Show the path to the c"
},
{
"path": "etc/man/man1/upterm-config-view.1",
"chars": 896,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-config-view - View the config file c"
},
{
"path": "etc/man/man1/upterm-config.1",
"chars": 867,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-config - Manage upterm configuration"
},
{
"path": "etc/man/man1/upterm-host.1",
"chars": 3981,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-host - Host a terminal session\n\n\n.SH"
},
{
"path": "etc/man/man1/upterm-proxy.1",
"chars": 864,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-proxy - Proxy a terminal session via"
},
{
"path": "etc/man/man1/upterm-session-current.1",
"chars": 1736,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-session-current - Display the curren"
},
{
"path": "etc/man/man1/upterm-session-info.1",
"chars": 724,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-session-info - Display terminal sess"
},
{
"path": "etc/man/man1/upterm-session-list.1",
"chars": 781,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-session-list - List shared sessions\n"
},
{
"path": "etc/man/man1/upterm-session.1",
"chars": 622,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-session - Display and manage termina"
},
{
"path": "etc/man/man1/upterm-upgrade.1",
"chars": 620,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-upgrade - Upgrade the CLI\n\n\n.SH SYNO"
},
{
"path": "etc/man/man1/upterm-version.1",
"chars": 481,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm-version - Show version\n\n\n.SH SYNOPSI"
},
{
"path": "etc/man/man1/upterm.1",
"chars": 2035,
"preview": ".nh\n.TH \"UPTERM\" \"1\" \"May 2026\" \"Upterm 0.0.0+dev\" \"Upterm Manual\"\n\n.SH NAME\nupterm - Instant Terminal Sharing\n\n\n.SH SYN"
},
{
"path": "fly.example.toml",
"chars": 1985,
"preview": "# Example Fly.io configuration for deploying your own uptermd server\n# Copy this file to fly.toml and customize the app "
},
{
"path": "fly.toml",
"chars": 1182,
"preview": "app = \"upterm\"\nkill_signal = \"SIGINT\"\nkill_timeout = \"5s\"\n\n[build]\ndockerfile = \"Dockerfile.uptermd\"\nbuild-target = \"upt"
},
{
"path": "ftests/client_test.go",
"chars": 15664,
"preview": "package ftests\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/owenther"
},
{
"path": "ftests/ftests_test.go",
"chars": 23890,
"preview": "package ftests\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\""
},
{
"path": "ftests/host_test.go",
"chars": 3722,
"preview": "package ftests\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"github.com/owe"
},
{
"path": "ftests/sftp_test.go",
"chars": 12360,
"preview": "package ftests\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github"
},
{
"path": "go.mod",
"chars": 7337,
"preview": "// +heroku goVersion 1.25.4\n// +heroku install ./cmd/uptermd/...\n\nmodule github.com/owenthereal/upterm\n\ngo 1.26\n\nrequire"
},
{
"path": "go.sum",
"chars": 63883,
"preview": "git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=\ngit.sr.ht/~jackmordaunt/go-toast"
},
{
"path": "host/adminclient.go",
"chars": 835,
"preview": "package host\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"google.golang.org/grpc\"\n\t\"g"
},
{
"path": "host/api/api.pb.go",
"chars": 19322,
"preview": "// Code generated by protoc-gen-go. DO NOT EDIT.\n// versions:\n// \tprotoc-gen-go v1.28.1\n// \tprotoc v3.21.6\n// sou"
},
{
"path": "host/api/api.proto",
"chars": 945,
"preview": "syntax = \"proto3\";\n\npackage api;\n\noption go_package = \"github.com/owenthereal/upterm/host/api\";\n\nservice AdminService {\n"
},
{
"path": "host/api/api_grpc.pb.go",
"chars": 3602,
"preview": "// Code generated by protoc-gen-go-grpc. DO NOT EDIT.\n// versions:\n// - protoc-gen-go-grpc v1.2.0\n// - protoc "
},
{
"path": "host/authorizedkeys.go",
"chars": 3802,
"preview": "package host\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"log/slog\"\n\n\t\"github.com/cli/go-gh"
},
{
"path": "host/host.go",
"chars": 13455,
"preview": "package host\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"l"
},
{
"path": "host/host_test.go",
"chars": 8094,
"preview": "package host\n\nimport (\n\t\"bytes\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/owenthereal/upterm/uti"
},
{
"path": "host/host_unix.go",
"chars": 484,
"preview": "//go:build !windows\n\npackage host\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"syscall\"\n\n\t\"github.com/oklog/run\"\n)\n\n// setupSignalHandle"
},
{
"path": "host/host_windows.go",
"chars": 1037,
"preview": "//go:build windows\n\npackage host\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/oklog/run\"\n)\n\n// setup"
},
{
"path": "host/internal/adminserver.go",
"chars": 1370,
"preview": "package internal\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"sync\"\n\n\t\"github.com/owenthereal/upterm/host/api\"\n\t\"google.golang.org/grpc"
},
{
"path": "host/internal/client.go",
"chars": 837,
"preview": "package internal\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/owenthereal/upterm/host/api\"\n)\n\nfunc NewClientRepo() *ClientRepo"
},
{
"path": "host/internal/command.go",
"chars": 3691,
"preview": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\n\t\"github.com/oklog/run\"\n\t\"github.com/olebedev/emitt"
},
{
"path": "host/internal/command_test.go",
"chars": 5362,
"preview": "package internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"runtime\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/olebedev/emitter\"\n\tuio \"github.c"
},
{
"path": "host/internal/command_unix.go",
"chars": 915,
"preview": "//go:build !windows\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/oklog/run\"\n\t\"gith"
},
{
"path": "host/internal/command_unix_test.go",
"chars": 3321,
"preview": "//go:build !windows\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\tptylib \"github.com/creack/pty\"\n\t\"g"
},
{
"path": "host/internal/command_windows.go",
"chars": 1334,
"preview": "//go:build windows\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/oklog/run\"\n\t\"github.com/olebedev/e"
},
{
"path": "host/internal/command_windows_test.go",
"chars": 6124,
"preview": "//go:build windows\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/olebedev/emitter\"\n\tuio "
},
{
"path": "host/internal/event.go",
"chars": 3143,
"preview": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\t\"log/slog\"\n\n\t\"github.com/olebedev/emitter\"\n\t\"github.com/"
},
{
"path": "host/internal/pty.go",
"chars": 1119,
"preview": "package internal\n\nimport \"io\"\n\n// PTY represents a pseudo-terminal abstraction that works across platforms.\n// On Unix, "
},
{
"path": "host/internal/pty_unix.go",
"chars": 2369,
"preview": "//go:build !windows\n\npackage internal\n\nimport (\n\t\"os\"\n\t\"os/exec\"\n\t\"sync\"\n\t\"syscall\"\n\n\tptylib \"github.com/creack/pty\"\n)\n\n"
},
{
"path": "host/internal/pty_windows.go",
"chars": 7690,
"preview": "//go:build windows\n\npackage internal\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"sync\"\n\t\"syscall\"\n\t\"unsafe\"\n\n\t\"github.com/"
},
{
"path": "host/internal/reversetunnel.go",
"chars": 4360,
"preview": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"net/url\"\n\t\"os/user\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/o"
},
{
"path": "host/internal/server.go",
"chars": 9257,
"preview": "package internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net\"\n\t\"os\"\n\t\"os/exec\"\n\t\"time\"\n\n\tgssh \"github.com/charmb"
},
{
"path": "host/internal/sftp.go",
"chars": 10625,
"preview": "package internal\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\tgssh \"github.co"
},
{
"path": "host/internal/sftp_test.go",
"chars": 4026,
"preview": "package internal\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/s"
},
{
"path": "host/sftp/permission.go",
"chars": 1820,
"preview": "package sftp\n\n// Operation represents an SFTP operation type\ntype Operation int\n\nconst (\n\tOpDownload Operation = iota\n\tO"
},
{
"path": "host/signer.go",
"chars": 3050,
"preview": "package host\n\nimport (\n\t\"bytes\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/owenthe"
},
{
"path": "host/signer_test.go",
"chars": 6609,
"preview": "package host\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n)\n\nconst (\n\t// Pass"
},
{
"path": "icon/upterm.go",
"chars": 77,
"preview": "package icon\n\nimport (\n\t_ \"embed\"\n)\n\n//go:embed upterm.png\nvar Upterm []byte\n"
},
{
"path": "internal/context/logging.go",
"chars": 433,
"preview": "package context\n\nimport (\n\t\"context\"\n\n\t\"github.com/owenthereal/upterm/internal/logging\"\n)\n\ntype contextKey string\n\nconst"
},
{
"path": "internal/e2e/e2e_test.go",
"chars": 13649,
"preview": "package e2e\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"git"
},
{
"path": "internal/e2e/sftp_test.go",
"chars": 6997,
"preview": "package e2e\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\n// wai"
},
{
"path": "internal/logging/logging.go",
"chars": 3905,
"preview": "package logging\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/getsentry/sen"
},
{
"path": "internal/testhelpers/consul.go",
"chars": 1456,
"preview": "package testhelpers\n\nimport (\n\t\"context\"\n\t\"net/url\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/hashicorp/consul/api\"\n)\n\nconst (\n\t// Con"
},
{
"path": "internal/version/version.go",
"chars": 5264,
"preview": "// Package version provides version management and compatibility checking for upterm/uptermd.\n//\n// This package central"
},
{
"path": "internal/version/version_test.go",
"chars": 4471,
"preview": "package version\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfu"
},
{
"path": "io/query_filter.go",
"chars": 7746,
"preview": "package io\n\nimport (\n\t\"io\"\n)\n\n// TerminalQueryFilter wraps an io.Writer and filters out terminal query\n// sequences from"
},
{
"path": "io/query_filter_test.go",
"chars": 7317,
"preview": "package io\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestTerminalQueryFilte"
},
{
"path": "io/reader.go",
"chars": 730,
"preview": "package io\n\nimport (\n\t\"context\"\n\t\"io\"\n)\n\nfunc NewContextReader(ctx context.Context, r io.Reader) io.Reader {\n\treturn con"
},
{
"path": "io/reader_test.go",
"chars": 2370,
"preview": "package io\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/go-cmp/cmp\"\n)\n\nfunc Test_ContextR"
},
{
"path": "io/writer.go",
"chars": 1936,
"preview": "package io\n\nimport (\n\t\"io\"\n\t\"sync\"\n)\n\ntype buffer struct {\n\tmu sync.Mutex\n\n\tqueue [][]byte\n\tsize int\n}\n\nfunc (c *buffer"
},
{
"path": "io/writer_test.go",
"chars": 1021,
"preview": "package io\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_MultiWriter(t *testin"
},
{
"path": "memlistener/memlistener.go",
"chars": 2540,
"preview": "package memlistener\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\n\t\"google.golang.org/grpc/test/bufconn\"\n)\n\nvar (\n\terrMissi"
},
{
"path": "memlistener/memlistener_test.go",
"chars": 1390,
"preview": "package memlistener\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n)\n\nfunc Test_MemListener_Listen(t *"
},
{
"path": "routing/encoding.go",
"chars": 3050,
"preview": "package routing\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"strings\"\n)\n\nvar (\n\tErrInvalidSSHUser = fmt.Errorf(\"invalid SSH use"
},
{
"path": "routing/encoding_test.go",
"chars": 3029,
"preview": "package routing\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/suite\"\n)\n\n// EncodeDecoderTestSuite tests the Encode"
},
{
"path": "routing/modes.go",
"chars": 297,
"preview": "package routing\n\n// Mode defines how session routing information is stored and encoded\ntype Mode string\n\nconst (\n\t// Mod"
},
{
"path": "script/changelog",
"chars": 438,
"preview": "#!/usr/bin/env bash\n\nset -e\n\nhead=\"${1:-HEAD}\"\n\nfor sha in `git rev-list -n 100 --first-parent \"$head\"^`; do\n previous_"
},
{
"path": "script/do-install",
"chars": 625,
"preview": "#!/usr/bin/env bash\n\nset -e\n\nfunction join { local IFS=\"$1\"; shift; echo \"$*\"; }\n\nSECRETS=($(ls -d $TF_VAR_uptermd_host"
},
{
"path": "script/heroku-install",
"chars": 402,
"preview": "#!/usr/bin/env bash\n\nset -e\n\nTERRAFORM_STATES_DIR=$(pwd)/terraform_states\nmkdir -p $TERRAFORM_STATES_DIR\n\npushd ./terra"
},
{
"path": "script/publish-release",
"chars": 569,
"preview": "#!/usr/bin/env bash\n\nset -e\n\nproject_name=\"owenthereal/upterm\"\ntag_name=\"${1?}\"\n[[ $tag_name == *-* ]] && pre=1 || pre=\n"
},
{
"path": "script/publish-website",
"chars": 848,
"preview": "#!/usr/bin/env bash\n\nset -e\n\n# Cleanup function\ncleanup() {\n if [ -d \"$tmp_dir\" ]; then\n rm -rf \"$tmp_dir\"\n "
},
{
"path": "script/tag-release",
"chars": 512,
"preview": "#!/usr/bin/env bash\n\nset -e\n\nversion_file=\"cmd/upterm/command/version.go\"\n\nif git diff --exit-code >/dev/null -- \"$versi"
},
{
"path": "server/cert.go",
"chars": 3890,
"preview": "package server\n\nimport (\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/owenthereal/upterm/upterm\"\n\t\"golang.org/x/crypto/ss"
},
{
"path": "server/metrics.go",
"chars": 620,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"sync\"\n\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n)\n\nt"
},
{
"path": "server/network.go",
"chars": 3979,
"preview": "package server\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/owenthereal/upterm/memlistener\"\n\t\"github.com"
},
{
"path": "server/server.go",
"chars": 14394,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"slices\"\n\t\"strings\"\n\t\"s"
},
{
"path": "server/server.pb.go",
"chars": 10866,
"preview": "// Code generated by protoc-gen-go. DO NOT EDIT.\n// versions:\n// \tprotoc-gen-go v1.28.1\n// \tprotoc v3.21.6\n// sou"
},
{
"path": "server/server.proto",
"chars": 486,
"preview": "syntax = \"proto3\";\n\npackage server;\n\noption go_package = \"github.com/owenthereal/upterm/server\";\n\nmessage CreateSessionR"
},
{
"path": "server/session.go",
"chars": 29606,
"preview": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n"
},
{
"path": "server/session_test.go",
"chars": 17436,
"preview": "package server\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/url\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/hashicorp/consul/api\"\n\t\"github.c"
},
{
"path": "server/sshd.go",
"chars": 3521,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/owenther"
},
{
"path": "server/sshd_test.go",
"chars": 2421,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/owenthereal/upterm/internal/loggi"
},
{
"path": "server/sshhandler.go",
"chars": 6386,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/charmbracelet/ssh\"\n\t\"github."
},
{
"path": "server/sshhandler_test.go",
"chars": 3755,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gi"
},
{
"path": "server/sshproxy.go",
"chars": 7807,
"preview": "package server\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"os\"\n\t\"sync\"\n\n\t\"github.com/go-kit/kit/metrics/provider\"\n\t\"github.com"
},
{
"path": "server/sshproxy_test.go",
"chars": 8906,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github"
},
{
"path": "server/sshrouting.go",
"chars": 5079,
"preview": "package server\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/go-kit/kit/metrics\"\n\t\"github."
},
{
"path": "server/wsproxy.go",
"chars": 4529,
"preview": "package server\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/web"
},
{
"path": "server/wsproxy_test.go",
"chars": 2292,
"preview": "package server\n\nimport (\n\t\"bufio\"\n\t\"io\"\n\t\"net\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cm"
},
{
"path": "systemd/uptermd.service",
"chars": 572,
"preview": "[Unit]\nDescription=upterm secure terminal sharing\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nExe"
},
{
"path": "terraform/digitalocean/charts.tf",
"chars": 3591,
"preview": "locals {\n ingress_nginx_values = {\n controller = {\n ingressClassResource = {\n name = \"nginx\"\n con"
},
{
"path": "terraform/digitalocean/do.tf",
"chars": 840,
"preview": "data \"digitalocean_kubernetes_versions\" \"k8s_version\" {}\n\nresource \"digitalocean_kubernetes_cluster\" \"upterm\" {\n name "
},
{
"path": "terraform/digitalocean/output.tf",
"chars": 180,
"preview": "output \"kubeconfig\" {\n depends_on = [digitalocean_kubernetes_cluster.upterm]\n value = digitalocean_kubernetes_clu"
},
{
"path": "terraform/digitalocean/providers.tf",
"chars": 381,
"preview": "terraform {\n required_providers {\n digitalocean = {\n source = \"digitalocean/digitalocean\"\n version = \"~> "
},
{
"path": "terraform/digitalocean/variables.tf",
"chars": 1074,
"preview": "### Digital Ocean ###\nvariable \"do_token\" {}\n\nvariable \"do_region\" {\n type = string\n default = \"sfo2\"\n}\n\nvariable \""
},
{
"path": "terraform/heroku/main.tf",
"chars": 2397,
"preview": "variable \"heroku_app_name\" {\n description = \"Heroku app name\"\n}\n\nvariable \"heroku_region\" {\n description = \"Heroku reg"
},
{
"path": "terraform/heroku/providers.tf",
"chars": 161,
"preview": "terraform {\n required_providers {\n heroku = {\n source = \"heroku/heroku\"\n version = \"5.2.1\"\n }\n }\n}\n\np"
},
{
"path": "upterm/const.go",
"chars": 828,
"preview": "package upterm\n\nconst (\n\t// host\n\tHostSSHClientVersion = \"SSH-2.0-upterm-host-client\"\n\tHostSSHServerVersion = \"SSH-2.0"
},
{
"path": "utils/testing.go",
"chars": 656,
"preview": "package utils\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"time\"\n)\n\n// WaitForServer waits for a server to be available at the g"
},
{
"path": "utils/utils.go",
"chars": 7807,
"preview": "package utils\n\nimport (\n\t\"crypto/ed25519\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n"
},
{
"path": "utils/utils_test.go",
"chars": 2527,
"preview": "package utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/t"
},
{
"path": "ws/client.go",
"chars": 1887,
"preview": "package ws\n\nimport (\n\t\"encoding/base64\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/gorilla/websocket\"\n\tchshare \"github."
}
]
About this extraction
This page contains the full source code of the jingweno/upterm GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 172 files (666.3 KB), approximately 213.0k tokens, and a symbol index with 911 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.