├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── release.yaml │ ├── build-and-release.yaml │ ├── codeql-analysis.yml │ └── build.yaml ├── icon ├── upterm.png └── upterm.go ├── Procfile ├── .gitignore ├── terraform ├── digitalocean │ ├── output.tf │ ├── providers.tf │ ├── do.tf │ ├── variables.tf │ └── charts.tf └── heroku │ ├── providers.tf │ └── main.tf ├── cmd ├── upterm │ ├── main.go │ └── command │ │ ├── host_unix.go │ │ ├── version.go │ │ ├── host_windows.go │ │ ├── host_test.go │ │ ├── proxy.go │ │ ├── privacy.go │ │ ├── internal │ │ └── tui │ │ │ └── host_session.go │ │ └── upgrade.go ├── uptermd │ ├── main.go │ └── command │ │ ├── version.go │ │ └── root.go ├── gendoc │ └── main.go └── uptermd-fly │ └── main.go ├── charts └── uptermd │ ├── templates │ ├── secret.yaml │ ├── serviceaccount.yaml │ ├── tests │ │ └── test-connection.yaml │ ├── service.yaml │ ├── issuer.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── NOTES.txt │ ├── _helpers.tpl │ └── deployment.yaml │ ├── Chart.yaml │ ├── .helmignore │ └── values.yaml ├── routing ├── modes.go ├── encoding_test.go └── encoding.go ├── app.json ├── script ├── heroku-install ├── changelog ├── tag-release ├── publish-release ├── do-install └── publish-website ├── docs ├── upterm_version.md ├── upterm_upgrade.md ├── upterm_session.md ├── upterm_session_info.md ├── upterm_session_list.md ├── upterm_config_path.md ├── upterm_proxy.md ├── upterm_config_view.md ├── upterm_config.md ├── upterm_config_edit.md ├── upterm_session_current.md ├── upterm.md └── upterm_host.md ├── internal ├── context │ └── logging.go ├── testhelpers │ └── consul.go └── logging │ └── logging.go ├── host ├── host_unix.go ├── adminclient.go ├── internal │ ├── client.go │ ├── command_unix.go │ ├── pty.go │ ├── command_windows.go │ ├── adminserver.go │ ├── pty_unix.go │ ├── event.go │ ├── command_unix_test.go │ ├── command.go │ └── reversetunnel.go ├── api │ ├── api.proto │ └── api_grpc.pb.go ├── host_windows.go ├── signer.go └── authorizedkeys.go ├── server ├── server.proto ├── metrics.go ├── sshd_test.go ├── wsproxy_test.go ├── cert.go ├── sshproxy_test.go ├── sshd.go ├── sshhandler_test.go └── network.go ├── etc └── man │ └── man1 │ ├── upterm-version.1 │ ├── upterm-session.1 │ ├── upterm-upgrade.1 │ ├── upterm-session-info.1 │ ├── upterm-session-list.1 │ ├── upterm-config-path.1 │ ├── upterm-proxy.1 │ ├── upterm-config.1 │ ├── upterm-config-view.1 │ ├── upterm-config-edit.1 │ ├── upterm-session-current.1 │ ├── upterm.1 │ └── upterm-host.1 ├── systemd └── uptermd.service ├── utils ├── testing.go └── utils_test.go ├── io ├── reader.go ├── writer_test.go ├── writer.go └── reader_test.go ├── upterm └── const.go ├── Dockerfile.uptermd ├── fly.toml ├── memlistener ├── memlistener_test.go └── memlistener.go ├── Makefile ├── ws └── client.go ├── fly.example.toml ├── ftests └── host_test.go └── CONTRIBUTING.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: owenthereal 2 | open_collective: upterm 3 | -------------------------------------------------------------------------------- /icon/upterm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenthereal/upterm/HEAD/icon/upterm.png -------------------------------------------------------------------------------- /icon/upterm.go: -------------------------------------------------------------------------------- 1 | package icon 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed upterm.png 8 | var Upterm []byte 9 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | 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 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | c.out 3 | release 4 | .terraform 5 | *.tfstate 6 | *.tfstate.backup 7 | dist 8 | bin 9 | CLAUDE.md 10 | .claude/ 11 | *.exe 12 | *.exe~ 13 | -------------------------------------------------------------------------------- /terraform/digitalocean/output.tf: -------------------------------------------------------------------------------- 1 | output "kubeconfig" { 2 | depends_on = [digitalocean_kubernetes_cluster.upterm] 3 | value = digitalocean_kubernetes_cluster.upterm.kube_config[0].raw_config 4 | sensitive = true 5 | } 6 | -------------------------------------------------------------------------------- /terraform/heroku/providers.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | heroku = { 4 | source = "heroku/heroku" 5 | version = "5.2.1" 6 | } 7 | } 8 | } 9 | 10 | provider "heroku" { 11 | } 12 | 13 | provider "tls" { 14 | } 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "gomod" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /cmd/upterm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "github.com/owenthereal/upterm/cmd/upterm/command" 8 | ) 9 | 10 | func main() { 11 | if err := command.Root().Execute(); err != nil { 12 | slog.Error("Error executing command", "error", err) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cmd/uptermd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "github.com/owenthereal/upterm/cmd/uptermd/command" 8 | ) 9 | 10 | func main() { 11 | if err := command.Root().Execute(); err != nil { 12 | slog.Error("command execution failed", "error", err) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /charts/uptermd/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: {{ include "upterm.fullname" . }} 5 | labels: 6 | {{- include "upterm.labels" . | nindent 4 }} 7 | type: Opaque 8 | data: 9 | {{- range $key, $val := .Values.host_keys }} 10 | {{ $key }}: {{ $val }} 11 | {{- end }} 12 | -------------------------------------------------------------------------------- /cmd/upterm/command/host_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package command 4 | 5 | import ( 6 | "os" 7 | ) 8 | 9 | // getDefaultShell returns the default shell on Unix systems 10 | func getDefaultShell() string { 11 | shell := os.Getenv("SHELL") 12 | if shell == "" { 13 | shell = "/bin/sh" 14 | } 15 | return shell 16 | } 17 | -------------------------------------------------------------------------------- /routing/modes.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | // Mode defines how session routing information is stored and encoded 4 | type Mode string 5 | 6 | const ( 7 | // ModeEmbedded embeds node address in the session identifier (default) 8 | ModeEmbedded Mode = "embedded" 9 | // ModeConsul looks up node address from Consul 10 | ModeConsul Mode = "consul" 11 | ) 12 | -------------------------------------------------------------------------------- /charts/uptermd/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: uptermd 3 | description: Secure Terminal Sharing 4 | type: application 5 | version: 0.2.0 6 | appVersion: 0.14.3 7 | home: https://upterm.dev 8 | sources: 9 | - https://github.com/owenthereal/upterm 10 | dependencies: 11 | maintainers: 12 | - name: Owen Ou 13 | email: o@owenou.com 14 | url: https://github.com/owenthereal 15 | -------------------------------------------------------------------------------- /charts/uptermd/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "upterm.serviceAccountName" . }} 6 | labels: 7 | {{- include "upterm.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /cmd/upterm/command/version.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/owenthereal/upterm/internal/version" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func versionCmd() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "version", 11 | Short: "Show version", 12 | RunE: func(c *cobra.Command, args []string) error { 13 | version.PrintVersion("Upterm") 14 | return nil 15 | }, 16 | } 17 | 18 | return cmd 19 | } 20 | -------------------------------------------------------------------------------- /cmd/uptermd/command/version.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/owenthereal/upterm/internal/version" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | func versionCmd() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "version", 11 | Short: "Show version", 12 | RunE: func(c *cobra.Command, args []string) error { 13 | version.PrintVersion("Uptermd") 14 | return nil 15 | }, 16 | } 17 | 18 | return cmd 19 | } 20 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Upterm", 3 | "keywords": [ 4 | "golang", 5 | "terminal", 6 | "upterm", 7 | "uptermd" 8 | ], 9 | "website": "https://upterm.dev", 10 | "success_url": "/getting-started", 11 | "description": "Secure Terminal Sharing", 12 | "repository": "https://github.com/owenthereal/upterm", 13 | "buildpacks": [ 14 | { 15 | "url": "heroku/go" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /charts/uptermd/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/uptermd/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "upterm.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "upterm.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test-success 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "upterm.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /script/heroku-install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | TERRAFORM_STATES_DIR=$(pwd)/terraform_states 6 | mkdir -p $TERRAFORM_STATES_DIR 7 | 8 | pushd ./terraform/heroku 9 | 10 | echo "Initializing terraform..." 11 | terraform init 12 | 13 | echo "Applying terraform..." 14 | export TF_VAR_git_commit_sha="${TF_VAR_git_commit_sha:-$(git rev-parse HEAD)}" # default version to current HEAD 15 | terraform apply -state $TERRAFORM_STATES_DIR/heroku.tfstate 16 | 17 | popd > /dev/null 18 | -------------------------------------------------------------------------------- /terraform/digitalocean/providers.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | digitalocean = { 4 | source = "digitalocean/digitalocean" 5 | version = "~> 2.0" 6 | } 7 | helm = { 8 | source = "hashicorp/helm" 9 | version = "~> 2.0" 10 | } 11 | github = { 12 | source = "integrations/github" 13 | version = "~> 4.0" 14 | } 15 | } 16 | required_version = ">= 0.13" 17 | } 18 | 19 | provider "digitalocean" { 20 | token = var.do_token 21 | } 22 | -------------------------------------------------------------------------------- /docs/upterm_version.md: -------------------------------------------------------------------------------- 1 | ## upterm version 2 | 3 | Show version 4 | 5 | ``` 6 | upterm version [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for version 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 19 | ``` 20 | 21 | ### SEE ALSO 22 | 23 | * [upterm](upterm.md) - Instant Terminal Sharing 24 | 25 | ###### Auto generated by spf13/cobra on 29-Nov-2025 26 | -------------------------------------------------------------------------------- /script/changelog: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | head="${1:-HEAD}" 6 | 7 | for sha in `git rev-list -n 100 --first-parent "$head"^`; do 8 | previous_tag="$(git tag -l --points-at "$sha" 'v*' 2>/dev/null || true)" 9 | [ -z "$previous_tag" ] || break 10 | done 11 | 12 | if [ -z "$previous_tag" ]; then 13 | echo "Couldn't detect previous version tag" >&2 14 | exit 1 15 | fi 16 | 17 | git log --no-merges --format='%C(auto,green)* %s%C(auto,reset)%n%w(0,2,2)%+b' \ 18 | --reverse "${previous_tag}..${head}" 19 | -------------------------------------------------------------------------------- /internal/context/logging.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/owenthereal/upterm/internal/logging" 7 | ) 8 | 9 | type contextKey string 10 | 11 | const loggerKey contextKey = "logger" 12 | 13 | func WithLogger(ctx context.Context, logger *logging.Logger) context.Context { 14 | return context.WithValue(ctx, loggerKey, logger) 15 | } 16 | 17 | func Logger(ctx context.Context) *logging.Logger { 18 | if logger, ok := ctx.Value(loggerKey).(*logging.Logger); ok { 19 | return logger 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /host/host_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package host 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "syscall" 9 | 10 | "github.com/oklog/run" 11 | ) 12 | 13 | // setupSignalHandler configures OS signal handling for Unix systems. 14 | // Listens for both SIGINT (Ctrl+C) and SIGTERM for graceful shutdown. 15 | // On Unix, PTY isolation ensures that Ctrl+C sent to upterm's terminal 16 | // doesn't affect child processes in the PTY. 17 | func setupSignalHandler(g *run.Group, ctx context.Context) { 18 | g.Add(run.SignalHandler(ctx, os.Interrupt, syscall.SIGTERM)) 19 | } 20 | -------------------------------------------------------------------------------- /server/server.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package server; 4 | 5 | option go_package = "github.com/owenthereal/upterm/server"; 6 | 7 | message CreateSessionRequest { 8 | string hostUser = 1; 9 | repeated bytes hostPublicKeys = 2; 10 | repeated bytes clientAuthorizedKeys = 3; 11 | } 12 | 13 | message CreateSessionResponse { 14 | string sessionID = 1; 15 | string nodeAddr = 2; 16 | string ssh_user = 3; // SSH username for client connections 17 | } 18 | 19 | message AuthRequest { 20 | string client_version = 1; 21 | string remote_addr = 2; 22 | bytes authorized_key = 3; 23 | } 24 | -------------------------------------------------------------------------------- /script/tag-release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | version_file="cmd/upterm/command/version.go" 6 | 7 | if git diff --exit-code >/dev/null -- "$version_file"; then 8 | echo "Update the version in $version_file and try again." >&2 9 | exit 1 10 | fi 11 | 12 | version="$(grep -w 'Version =' "$version_file" | cut -d'"' -f2)" 13 | 14 | sed -i'' "s/appVersion: .*/appVersion: $version/g" charts/uptermd/Chart.yaml 15 | 16 | make docs 17 | git commit -m "Release Upterm $version" -- "$version_file" "docs/*" "etc/*" "charts/*" 18 | 19 | git tag "v${version}" 20 | 21 | git push origin HEAD "v${version}" 22 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-version.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-version - Show version 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm version [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Show version 14 | 15 | 16 | .SH OPTIONS 17 | \fB-h\fP, \fB--help\fP[=false] 18 | help for version 19 | 20 | 21 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 22 | \fB--debug\fP[=false] 23 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 24 | 25 | 26 | .SH SEE ALSO 27 | \fBupterm(1)\fP 28 | 29 | 30 | .SH HISTORY 31 | 29-Nov-2025 Auto generated by spf13/cobra 32 | -------------------------------------------------------------------------------- /script/publish-release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | project_name="owenthereal/upterm" 6 | tag_name="${1?}" 7 | [[ $tag_name == *-* ]] && pre=1 || pre= 8 | 9 | notes="$(git tag --list "$tag_name" --format='%(contents:subject)%0a%0a%(contents:body)')" 10 | 11 | if hub release --include-drafts | grep -q "^${tag_name}\$"; then 12 | hub release edit "$tag_name" -m "" 13 | elif [ $(wc -l <<<"$notes") -gt 1 ]; then 14 | hub release create ${pre:+--prerelease} - "$tag_name" <<<"$notes" 15 | else 16 | { echo "${project_name} ${tag_name#v}" 17 | echo 18 | bin/changelog 19 | } | hub release create --draft ${pre:+--prerelease} - "$tag_name" 20 | fi 21 | -------------------------------------------------------------------------------- /charts/uptermd/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "upterm.fullname" . }} 5 | labels: 6 | {{- include "upterm.labels" . | nindent 4 }} 7 | {{- with .Values.service.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | spec: 12 | type: {{ .Values.service.type }} 13 | ports: 14 | - port: 22 15 | protocol: TCP 16 | targetPort: 22 17 | name: sshd 18 | {{- if .Values.websocket.enabled }} 19 | - port: 80 20 | protocol: TCP 21 | targetPort: 80 22 | name: ws 23 | {{- end }} 24 | selector: 25 | {{- include "upterm.selectorLabels" . | nindent 4 }} 26 | -------------------------------------------------------------------------------- /docs/upterm_upgrade.md: -------------------------------------------------------------------------------- 1 | ## upterm upgrade 2 | 3 | Upgrade the CLI 4 | 5 | ``` 6 | upterm upgrade [flags] 7 | ``` 8 | 9 | ### Examples 10 | 11 | ``` 12 | # Upgrade to the latest version: 13 | upterm upgrade 14 | 15 | # Upgrade to a specific version: 16 | upterm upgrade 0.2.0 17 | ``` 18 | 19 | ### Options 20 | 21 | ``` 22 | -h, --help help for upgrade 23 | ``` 24 | 25 | ### Options inherited from parent commands 26 | 27 | ``` 28 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 29 | ``` 30 | 31 | ### SEE ALSO 32 | 33 | * [upterm](upterm.md) - Instant Terminal Sharing 34 | 35 | ###### Auto generated by spf13/cobra on 29-Nov-2025 36 | -------------------------------------------------------------------------------- /systemd/uptermd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=upterm secure terminal sharing 3 | After=network-online.target 4 | Wants=network-online.target 5 | 6 | [Service] 7 | ExecStart=/usr/bin/uptermd --ssh-addr 0.0.0.0:2222 8 | 9 | IPAccounting=yes 10 | IPAddressAllow=localhost 11 | IPAddressDeny=any 12 | DynamicUser=yes 13 | PrivateTmp=yes 14 | PrivateUsers=yes 15 | PrivateDevices=yes 16 | NoNewPrivileges=true 17 | ProtectSystem=strict 18 | ProtectHome=yes 19 | ProtectClock=yes 20 | ProtectControlGroups=yes 21 | ProtectKernelLogs=yes 22 | ProtectKernelModules=yes 23 | ProtectKernelTunables=yes 24 | ProtectProc=invisible 25 | CapabilityBoundingSet=CAP_NET_BIND_SERVICE 26 | 27 | [Install] 28 | WantedBy=multi-user.target 29 | -------------------------------------------------------------------------------- /docs/upterm_session.md: -------------------------------------------------------------------------------- 1 | ## upterm session 2 | 3 | Display and manage terminal sessions 4 | 5 | ### Options 6 | 7 | ``` 8 | -h, --help help for session 9 | ``` 10 | 11 | ### Options inherited from parent commands 12 | 13 | ``` 14 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 15 | ``` 16 | 17 | ### SEE ALSO 18 | 19 | * [upterm](upterm.md) - Instant Terminal Sharing 20 | * [upterm session current](upterm_session_current.md) - Display the current terminal session 21 | * [upterm session info](upterm_session_info.md) - Display terminal session by name 22 | * [upterm session list](upterm_session_list.md) - List shared sessions 23 | 24 | ###### Auto generated by spf13/cobra on 29-Nov-2025 25 | -------------------------------------------------------------------------------- /cmd/upterm/command/host_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package command 4 | 5 | import ( 6 | "os/exec" 7 | ) 8 | 9 | // getDefaultShell returns the default shell on Windows 10 | // Prefers PowerShell Core (pwsh) if available, otherwise falls back to cmd.exe 11 | func getDefaultShell() string { 12 | // Check for PowerShell Core first 13 | if _, err := exec.LookPath("pwsh"); err == nil { 14 | // -NoLogo suppresses the copyright banner 15 | return "pwsh -NoLogo" 16 | } 17 | 18 | // Check for PowerShell 19 | if _, err := exec.LookPath("powershell"); err == nil { 20 | // -NoLogo suppresses the copyright banner 21 | return "powershell -NoLogo" 22 | } 23 | 24 | // Fallback to cmd.exe (always available on Windows) 25 | return "cmd.exe" 26 | } 27 | -------------------------------------------------------------------------------- /script/do-install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | function join { local IFS="$1"; shift; echo "$*"; } 6 | 7 | SECRETS=($(ls -d $TF_VAR_uptermd_host_keys_dir)) 8 | 9 | ARRAY=() 10 | for f in ${SECRETS[@]} 11 | do 12 | ARRAY+=("\"$(basename $f)\"=\"$(cat $f | base64 -w 0)\"") 13 | done 14 | 15 | HOST_KEYS="{" 16 | HOST_KEYS+=$(join , ${ARRAY[@]}) 17 | HOST_KEYS+="}" 18 | 19 | TERRAFORM_STATES_DIR=$(PWD)/terraform_states 20 | mkdir -p $TERRAFORM_STATES_DIR 21 | 22 | pushd ./terraform/digitalocean > /dev/null 23 | 24 | echo "Initializing terraform..." 25 | terraform init 26 | 27 | echo "Applying terraform..." 28 | terraform apply \ 29 | -state $TERRAFORM_STATES_DIR/digitalocean.tfstate \ 30 | -var uptermd_host_keys="$HOST_KEYS" 31 | 32 | popd > /dev/null 33 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-session.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-session - Display and manage terminal sessions 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm session [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Display and manage terminal sessions 14 | 15 | 16 | .SH OPTIONS 17 | \fB-h\fP, \fB--help\fP[=false] 18 | help for session 19 | 20 | 21 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 22 | \fB--debug\fP[=false] 23 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 24 | 25 | 26 | .SH SEE ALSO 27 | \fBupterm(1)\fP, \fBupterm-session-current(1)\fP, \fBupterm-session-info(1)\fP, \fBupterm-session-list(1)\fP 28 | 29 | 30 | .SH HISTORY 31 | 29-Nov-2025 Auto generated by spf13/cobra 32 | -------------------------------------------------------------------------------- /server/metrics.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | ) 10 | 11 | type metricServer struct { 12 | server *http.Server 13 | mux sync.Mutex 14 | } 15 | 16 | func (m *metricServer) Shutdown(ctx context.Context) error { 17 | m.mux.Lock() 18 | defer m.mux.Unlock() 19 | 20 | if m.server == nil { 21 | return nil 22 | } 23 | 24 | return m.server.Shutdown(ctx) 25 | } 26 | 27 | func (m *metricServer) ListenAndServe(addr string) error { 28 | mux := http.NewServeMux() 29 | mux.Handle("/metrics", promhttp.Handler()) 30 | 31 | m.mux.Lock() 32 | m.server = &http.Server{ 33 | Addr: addr, 34 | Handler: mux, 35 | } 36 | m.mux.Unlock() 37 | 38 | return m.server.ListenAndServe() 39 | } 40 | -------------------------------------------------------------------------------- /utils/testing.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "time" 8 | ) 9 | 10 | // WaitForServer waits for a server to be available at the given address with context support 11 | func WaitForServer(ctx context.Context, addr string) error { 12 | ticker := time.NewTicker(100 * time.Millisecond) 13 | defer ticker.Stop() 14 | 15 | for { 16 | select { 17 | case <-ctx.Done(): 18 | return fmt.Errorf("timeout waiting for server at %s: %w", addr, ctx.Err()) 19 | case <-ticker.C: 20 | conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) 21 | if err != nil { 22 | continue 23 | } 24 | 25 | if err := conn.Close(); err != nil { 26 | return fmt.Errorf("error closing connection: %w", err) 27 | } 28 | 29 | return nil 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-upgrade.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-upgrade - Upgrade the CLI 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm upgrade [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Upgrade the CLI 14 | 15 | 16 | .SH OPTIONS 17 | \fB-h\fP, \fB--help\fP[=false] 18 | help for upgrade 19 | 20 | 21 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 22 | \fB--debug\fP[=false] 23 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 24 | 25 | 26 | .SH EXAMPLE 27 | .EX 28 | # Upgrade to the latest version: 29 | upterm upgrade 30 | 31 | # Upgrade to a specific version: 32 | upterm upgrade 0.2.0 33 | .EE 34 | 35 | 36 | .SH SEE ALSO 37 | \fBupterm(1)\fP 38 | 39 | 40 | .SH HISTORY 41 | 29-Nov-2025 Auto generated by spf13/cobra 42 | -------------------------------------------------------------------------------- /charts/uptermd/templates/issuer.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.websocket.enabled }} 2 | apiVersion: cert-manager.io/v1 3 | kind: Issuer 4 | metadata: 5 | name: {{ include "upterm.fullname" . }}-letsencrypt 6 | labels: 7 | {{- include "upterm.labels" . | nindent 4 }} 8 | spec: 9 | acme: 10 | # The ACME server URL 11 | server: https://acme-v02.api.letsencrypt.org/directory 12 | # Email address used for ACME registration 13 | email: {{ .Values.websocket.cert_manager_acme_email }} 14 | # Name of a secret used to store the ACME account private key 15 | privateKeySecretRef: 16 | name: {{ include "upterm.fullname" . }}-letsencrypt 17 | # Enable the HTTP-01 challenge provider 18 | solvers: 19 | - http01: 20 | ingress: 21 | class: {{ .Values.websocket.ingress_nginx_ingress_class }} 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /docs/upterm_session_info.md: -------------------------------------------------------------------------------- 1 | ## upterm session info 2 | 3 | Display terminal session by name 4 | 5 | ### Synopsis 6 | 7 | Display terminal session by name. 8 | 9 | ``` 10 | upterm session info [flags] 11 | ``` 12 | 13 | ### Examples 14 | 15 | ``` 16 | # Display session by name: 17 | upterm session info NAME 18 | ``` 19 | 20 | ### Options 21 | 22 | ``` 23 | -h, --help help for info 24 | --hide-client-ip Hide client IP addresses from output (auto-enabled in CI environments). 25 | ``` 26 | 27 | ### Options inherited from parent commands 28 | 29 | ``` 30 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [upterm session](upterm_session.md) - Display and manage terminal sessions 36 | 37 | ###### Auto generated by spf13/cobra on 29-Nov-2025 38 | -------------------------------------------------------------------------------- /docs/upterm_session_list.md: -------------------------------------------------------------------------------- 1 | ## upterm session list 2 | 3 | List shared sessions 4 | 5 | ### Synopsis 6 | 7 | List shared sessions. 8 | 9 | Sockets are stored in: /run/user/1000/upterm 10 | 11 | Follows the XDG Base Directory Specification with fallback to $HOME/.upterm 12 | in constrained environments where XDG directories are unavailable. 13 | 14 | ``` 15 | upterm session list [flags] 16 | ``` 17 | 18 | ### Examples 19 | 20 | ``` 21 | # List shared sessions: 22 | upterm session list 23 | ``` 24 | 25 | ### Options 26 | 27 | ``` 28 | -h, --help help for list 29 | ``` 30 | 31 | ### Options inherited from parent commands 32 | 33 | ``` 34 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 35 | ``` 36 | 37 | ### SEE ALSO 38 | 39 | * [upterm session](upterm_session.md) - Display and manage terminal sessions 40 | 41 | ###### Auto generated by spf13/cobra on 29-Nov-2025 42 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-session-info.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-session-info - Display terminal session by name 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm session info [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Display terminal session by name. 14 | 15 | 16 | .SH OPTIONS 17 | \fB-h\fP, \fB--help\fP[=false] 18 | help for info 19 | 20 | .PP 21 | \fB--hide-client-ip\fP[=false] 22 | Hide client IP addresses from output (auto-enabled in CI environments). 23 | 24 | 25 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 26 | \fB--debug\fP[=false] 27 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 28 | 29 | 30 | .SH EXAMPLE 31 | .EX 32 | # Display session by name: 33 | upterm session info NAME 34 | .EE 35 | 36 | 37 | .SH SEE ALSO 38 | \fBupterm-session(1)\fP 39 | 40 | 41 | .SH HISTORY 42 | 29-Nov-2025 Auto generated by spf13/cobra 43 | -------------------------------------------------------------------------------- /docs/upterm_config_path.md: -------------------------------------------------------------------------------- 1 | ## upterm config path 2 | 3 | Show the path to the config file 4 | 5 | ### Synopsis 6 | 7 | Show the path to the config file. 8 | 9 | Config file: /home/user/.config/upterm/config.yaml 10 | 11 | The config file is optional and created manually by users. 12 | 13 | ``` 14 | upterm config path [flags] 15 | ``` 16 | 17 | ### Examples 18 | 19 | ``` 20 | # Show config file path: 21 | upterm config path 22 | 23 | # Create config file directory: 24 | mkdir -p "$(dirname "$(upterm config path)")" 25 | ``` 26 | 27 | ### Options 28 | 29 | ``` 30 | -h, --help help for path 31 | ``` 32 | 33 | ### Options inherited from parent commands 34 | 35 | ``` 36 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 37 | ``` 38 | 39 | ### SEE ALSO 40 | 41 | * [upterm config](upterm_config.md) - Manage upterm configuration 42 | 43 | ###### Auto generated by spf13/cobra on 29-Nov-2025 44 | -------------------------------------------------------------------------------- /io/reader.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | func NewContextReader(ctx context.Context, r io.Reader) io.Reader { 9 | return contextReader{ 10 | Reader: r, 11 | ctx: ctx, 12 | } 13 | } 14 | 15 | type contextReader struct { 16 | io.Reader 17 | ctx context.Context 18 | } 19 | 20 | type readResult struct { 21 | n int 22 | err error 23 | } 24 | 25 | func (r contextReader) Read(p []byte) (n int, err error) { 26 | c := make(chan readResult, 1) 27 | 28 | go func(ctx context.Context, reader io.Reader) { 29 | // close by the sender 30 | defer close(c) 31 | 32 | // return early if context is done 33 | select { 34 | case <-ctx.Done(): 35 | return 36 | default: 37 | } 38 | 39 | n, err := reader.Read(p) 40 | c <- readResult{n, err} 41 | }(r.ctx, r.Reader) 42 | 43 | select { 44 | case rr := <-c: 45 | return rr.n, rr.err 46 | case <-r.ctx.Done(): 47 | return 0, r.ctx.Err() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /upterm/const.go: -------------------------------------------------------------------------------- 1 | package upterm 2 | 3 | const ( 4 | // host 5 | HostSSHClientVersion = "SSH-2.0-upterm-host-client" 6 | HostSSHServerVersion = "SSH-2.0-upterm-host-server" 7 | HostAdminSocketEnvVar = "UPTERM_ADMIN_SOCKET" 8 | 9 | // client 10 | ClientSSHClientVersion = "SSH-2.0-upterm-client-client" 11 | 12 | // server 13 | ServerSSHServerVersion = "SSH-2.0-uptermd" 14 | ServerServerInfoRequestType = "upterm-server-info@upterm.dev" 15 | ServerCreateSessionRequestType = "upterm-create-session@upterm.dev" 16 | 17 | // header 18 | HeaderUptermClientVersion = "Upterm-Client-Version" 19 | 20 | // misc 21 | OpenSSHKeepAliveRequestType = "keepalive@openssh.com" 22 | 23 | SSHCertExtension = "upterm-auth-request" 24 | 25 | EventClientJoined = "client-joined" 26 | EventClientLeft = "client-left" 27 | EventTerminalWindowChanged = "terminal-window-changed" 28 | EventTerminalDetached = "terminal-detached" 29 | ) 30 | -------------------------------------------------------------------------------- /terraform/digitalocean/do.tf: -------------------------------------------------------------------------------- 1 | data "digitalocean_kubernetes_versions" "k8s_version" {} 2 | 3 | resource "digitalocean_kubernetes_cluster" "upterm" { 4 | name = var.do_k8s_name 5 | region = var.do_region 6 | auto_upgrade = false 7 | version = data.digitalocean_kubernetes_versions.k8s_version.latest_version 8 | 9 | node_pool { 10 | name = "autoscale-worker-pool" 11 | size = var.do_node_size 12 | auto_scale = true 13 | min_nodes = var.do_min_nodes 14 | max_nodes = var.do_max_nodes 15 | tags = [var.do_k8s_name] 16 | labels = { "app" = var.do_k8s_name } 17 | } 18 | } 19 | 20 | resource "local_file" "kubeconfig" { 21 | count = var.write_kubeconfig ? 1 : 0 22 | content = digitalocean_kubernetes_cluster.upterm.kube_config[0].raw_config 23 | filename = var.kubeconfig_path 24 | file_permission = "0644" 25 | directory_permission = "0755" 26 | } 27 | -------------------------------------------------------------------------------- /docs/upterm_proxy.md: -------------------------------------------------------------------------------- 1 | ## upterm proxy 2 | 3 | Proxy a terminal session via WebSocket 4 | 5 | ### Synopsis 6 | 7 | Proxy a terminal session via WebSocket, to be used alongside SSH ProxyCommand. 8 | 9 | ``` 10 | upterm proxy [flags] 11 | ``` 12 | 13 | ### Examples 14 | 15 | ``` 16 | # Host shares a session running $SHELL over WebSocket: 17 | upterm host --server wss://uptermd.upterm.dev -- YOUR_COMMAND 18 | 19 | # Client connects to the host session via WebSocket: 20 | ssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN:uptermd.uptermd.dev:443 21 | ``` 22 | 23 | ### Options 24 | 25 | ``` 26 | -h, --help help for proxy 27 | ``` 28 | 29 | ### Options inherited from parent commands 30 | 31 | ``` 32 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 33 | ``` 34 | 35 | ### SEE ALSO 36 | 37 | * [upterm](upterm.md) - Instant Terminal Sharing 38 | 39 | ###### Auto generated by spf13/cobra on 29-Nov-2025 40 | -------------------------------------------------------------------------------- /host/adminclient.go: -------------------------------------------------------------------------------- 1 | package host 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/owenthereal/upterm/host/api" 9 | "google.golang.org/grpc" 10 | "google.golang.org/grpc/credentials/insecure" 11 | ) 12 | 13 | const ( 14 | AdminSockExt = ".sock" 15 | ) 16 | 17 | func AdminSocketFile(sessionID string) string { 18 | return fmt.Sprintf("%s%s", sessionID, AdminSockExt) 19 | } 20 | 21 | func AdminClient(socket string) (api.AdminServiceClient, error) { 22 | // Use mtls 23 | // Workaround for gRPC Unix socket support on Windows: https://github.com/grpc/grpc-go/issues/8675 24 | conn, err := grpc.NewClient( 25 | "passthrough:///unix", 26 | grpc.WithTransportCredentials(insecure.NewCredentials()), 27 | grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { 28 | return (&net.Dialer{}).DialContext(ctx, "unix", socket) 29 | }), 30 | ) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return api.NewAdminServiceClient(conn), nil 36 | } 37 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-session-list.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-session-list - List shared sessions 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm session list [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | List shared sessions. 14 | 15 | .PP 16 | Sockets are stored in: /run/user/1000/upterm 17 | 18 | .PP 19 | Follows the XDG Base Directory Specification with fallback to $HOME/.upterm 20 | in constrained environments where XDG directories are unavailable. 21 | 22 | 23 | .SH OPTIONS 24 | \fB-h\fP, \fB--help\fP[=false] 25 | help for list 26 | 27 | 28 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 29 | \fB--debug\fP[=false] 30 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 31 | 32 | 33 | .SH EXAMPLE 34 | .EX 35 | # List shared sessions: 36 | upterm session list 37 | .EE 38 | 39 | 40 | .SH SEE ALSO 41 | \fBupterm-session(1)\fP 42 | 43 | 44 | .SH HISTORY 45 | 29-Nov-2025 Auto generated by spf13/cobra 46 | -------------------------------------------------------------------------------- /script/publish-website: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # Cleanup function 6 | cleanup() { 7 | if [ -d "$tmp_dir" ]; then 8 | rm -rf "$tmp_dir" 9 | fi 10 | if [ -n "$current_branch" ] && [ "$(git rev-parse --abbrev-ref HEAD)" != "$current_branch" ]; then 11 | git checkout "$current_branch" 2>/dev/null || true 12 | fi 13 | } 14 | trap cleanup EXIT 15 | 16 | current_branch=$(git rev-parse --abbrev-ref HEAD) 17 | tmp_dir=$(mktemp -d -t upterm-XXXXXXXXXX) 18 | upterm_dir=${PWD} 19 | 20 | pushd $tmp_dir 21 | helm package $upterm_dir/charts/uptermd && helm repo index . 22 | cp $upterm_dir/README.md index.md 23 | cp -r $upterm_dir/docs . 24 | cp $upterm_dir/fly.example.toml . 25 | popd > /dev/null 26 | 27 | git checkout gh-pages 28 | cp -r $tmp_dir/* . 29 | cp -r $tmp_dir/.* . 2>/dev/null || true 30 | 31 | git add . 32 | if git diff --staged --quiet; then 33 | echo "No changes to commit" 34 | else 35 | git commit -m "Generated website" 36 | git push origin gh-pages 37 | fi 38 | 39 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-config-path.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-config-path - Show the path to the config file 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm config path [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Show the path to the config file. 14 | 15 | .PP 16 | Config file: /home/user/.config/upterm/config.yaml 17 | 18 | .PP 19 | The config file is optional and created manually by users. 20 | 21 | 22 | .SH OPTIONS 23 | \fB-h\fP, \fB--help\fP[=false] 24 | help for path 25 | 26 | 27 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 28 | \fB--debug\fP[=false] 29 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 30 | 31 | 32 | .SH EXAMPLE 33 | .EX 34 | # Show config file path: 35 | upterm config path 36 | 37 | # Create config file directory: 38 | mkdir -p "$(dirname "$(upterm config path)")" 39 | .EE 40 | 41 | 42 | .SH SEE ALSO 43 | \fBupterm-config(1)\fP 44 | 45 | 46 | .SH HISTORY 47 | 29-Nov-2025 Auto generated by spf13/cobra 48 | -------------------------------------------------------------------------------- /docs/upterm_config_view.md: -------------------------------------------------------------------------------- 1 | ## upterm config view 2 | 3 | View the config file contents 4 | 5 | ### Synopsis 6 | 7 | View the config file contents. 8 | 9 | Config file: /home/user/.config/upterm/config.yaml 10 | 11 | If the config file exists, this command displays its contents. If it doesn't 12 | exist, this command shows an example config file that you can use as a template. 13 | 14 | ``` 15 | upterm config view [flags] 16 | ``` 17 | 18 | ### Examples 19 | 20 | ``` 21 | # View current config: 22 | upterm config view 23 | 24 | # View and save as new config: 25 | upterm config view > "$(upterm config path)" 26 | ``` 27 | 28 | ### Options 29 | 30 | ``` 31 | -h, --help help for view 32 | ``` 33 | 34 | ### Options inherited from parent commands 35 | 36 | ``` 37 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 38 | ``` 39 | 40 | ### SEE ALSO 41 | 42 | * [upterm config](upterm_config.md) - Manage upterm configuration 43 | 44 | ###### Auto generated by spf13/cobra on 29-Nov-2025 45 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-proxy.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-proxy - Proxy a terminal session via WebSocket 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm proxy [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Proxy a terminal session via WebSocket, to be used alongside SSH ProxyCommand. 14 | 15 | 16 | .SH OPTIONS 17 | \fB-h\fP, \fB--help\fP[=false] 18 | help for proxy 19 | 20 | 21 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 22 | \fB--debug\fP[=false] 23 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 24 | 25 | 26 | .SH EXAMPLE 27 | .EX 28 | # Host shares a session running $SHELL over WebSocket: 29 | upterm host --server wss://uptermd.upterm.dev -- YOUR_COMMAND 30 | 31 | # Client connects to the host session via WebSocket: 32 | ssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN:uptermd.uptermd.dev:443 33 | .EE 34 | 35 | 36 | .SH SEE ALSO 37 | \fBupterm(1)\fP 38 | 39 | 40 | .SH HISTORY 41 | 29-Nov-2025 Auto generated by spf13/cobra 42 | -------------------------------------------------------------------------------- /host/internal/client.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/owenthereal/upterm/host/api" 8 | ) 9 | 10 | func NewClientRepo() *ClientRepo { 11 | return &ClientRepo{} 12 | } 13 | 14 | type ClientRepo struct { 15 | clients sync.Map 16 | } 17 | 18 | func (c *ClientRepo) Add(client *api.Client) error { 19 | _, loaded := c.clients.LoadOrStore(client.Id, client) 20 | if loaded { 21 | return fmt.Errorf("client already exists") 22 | } 23 | 24 | return nil 25 | } 26 | 27 | func (c *ClientRepo) Delete(clientId string) { 28 | c.clients.Delete(clientId) 29 | } 30 | 31 | func (c *ClientRepo) Get(clientId string) *api.Client { 32 | val, _ := c.clients.Load(clientId) 33 | if val != nil { 34 | return val.(*api.Client) 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func (c *ClientRepo) Clients() []*api.Client { 41 | var clients []*api.Client 42 | 43 | c.clients.Range(func(key, value interface{}) bool { 44 | cc := value.(*api.Client) 45 | clients = append(clients, cc) 46 | return true 47 | }) 48 | 49 | return clients 50 | } 51 | -------------------------------------------------------------------------------- /docs/upterm_config.md: -------------------------------------------------------------------------------- 1 | ## upterm config 2 | 3 | Manage upterm configuration 4 | 5 | ### Synopsis 6 | 7 | Manage upterm configuration file. 8 | 9 | Config file: /home/user/.config/upterm/config.yaml 10 | 11 | This follows the XDG Base Directory Specification. 12 | 13 | Configuration priority (highest to lowest): 14 | 1. Command-line flags 15 | 2. Environment variables (UPTERM_ prefix) 16 | 3. Config file 17 | 4. Default values 18 | 19 | ### Options 20 | 21 | ``` 22 | -h, --help help for config 23 | ``` 24 | 25 | ### Options inherited from parent commands 26 | 27 | ``` 28 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 29 | ``` 30 | 31 | ### SEE ALSO 32 | 33 | * [upterm](upterm.md) - Instant Terminal Sharing 34 | * [upterm config edit](upterm_config_edit.md) - Edit the config file 35 | * [upterm config path](upterm_config_path.md) - Show the path to the config file 36 | * [upterm config view](upterm_config_view.md) - View the config file contents 37 | 38 | ###### Auto generated by spf13/cobra on 29-Nov-2025 39 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-config.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-config - Manage upterm configuration 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm config [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Manage upterm configuration file. 14 | 15 | .PP 16 | Config file: /home/user/.config/upterm/config.yaml 17 | 18 | .PP 19 | This follows the XDG Base Directory Specification. 20 | 21 | .PP 22 | Configuration priority (highest to lowest): 23 | 1. Command-line flags 24 | 2. Environment variables (UPTERM_ prefix) 25 | 3. Config file 26 | 4. Default values 27 | 28 | 29 | .SH OPTIONS 30 | \fB-h\fP, \fB--help\fP[=false] 31 | help for config 32 | 33 | 34 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 35 | \fB--debug\fP[=false] 36 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 37 | 38 | 39 | .SH SEE ALSO 40 | \fBupterm(1)\fP, \fBupterm-config-edit(1)\fP, \fBupterm-config-path(1)\fP, \fBupterm-config-view(1)\fP 41 | 42 | 43 | .SH HISTORY 44 | 29-Nov-2025 Auto generated by spf13/cobra 45 | -------------------------------------------------------------------------------- /host/api/api.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package api; 4 | 5 | option go_package = "github.com/owenthereal/upterm/host/api"; 6 | 7 | service AdminService { 8 | rpc GetSession(GetSessionRequest) returns (GetSessionResponse) {} 9 | } 10 | 11 | message GetSessionRequest {} 12 | 13 | message GetSessionResponse { 14 | string session_id = 1; 15 | repeated string command = 2; 16 | repeated string force_command = 3; 17 | string host = 4; 18 | string node_addr = 5; 19 | repeated Client connected_clients = 6; 20 | repeated AuthorizedKey authorized_keys = 7; 21 | string ssh_user = 8; // SSH username for client connections 22 | } 23 | 24 | message AuthorizedKey { 25 | repeated string public_key_fingerprints = 1; 26 | string comment = 2; 27 | } 28 | 29 | message Client { 30 | string id = 1; 31 | string version = 2; 32 | string addr = 3; 33 | string public_key_fingerprint = 4; 34 | } 35 | 36 | message Identifier { 37 | string id = 1; 38 | Type type = 2; 39 | string node_addr = 3; 40 | 41 | enum Type { 42 | HOST = 0; 43 | CLIENT = 1; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /host/internal/command_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package internal 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/oklog/run" 12 | "github.com/olebedev/emitter" 13 | ) 14 | 15 | // setupTerminalResize sets up terminal resize handling for Unix systems using SIGWINCH 16 | func (c *command) setupTerminalResize(g *run.Group, stdin *os.File, ptmx PTY, eventEmitter *emitter.Emitter) { 17 | ch := make(chan os.Signal, 1) 18 | signal.Notify(ch, syscall.SIGWINCH) 19 | // Note: Initial size is already set in startPty, so we only handle resize events here 20 | ctx, cancel := context.WithCancel(c.ctx) 21 | tee := terminalEventEmitter{eventEmitter} 22 | g.Add(func() error { 23 | for { 24 | select { 25 | case <-ctx.Done(): 26 | close(ch) 27 | return ctx.Err() 28 | case <-ch: 29 | h, w, err := getPtysize(stdin) 30 | if err != nil { 31 | return err 32 | } 33 | tee.TerminalWindowChanged("local", ptmx, w, h) 34 | } 35 | } 36 | }, func(err error) { 37 | tee.TerminalDetached("local", ptmx) 38 | cancel() 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-config-view.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-config-view - View the config file contents 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm config view [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | View the config file contents. 14 | 15 | .PP 16 | Config file: /home/user/.config/upterm/config.yaml 17 | 18 | .PP 19 | If the config file exists, this command displays its contents. If it doesn't 20 | exist, this command shows an example config file that you can use as a template. 21 | 22 | 23 | .SH OPTIONS 24 | \fB-h\fP, \fB--help\fP[=false] 25 | help for view 26 | 27 | 28 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 29 | \fB--debug\fP[=false] 30 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 31 | 32 | 33 | .SH EXAMPLE 34 | .EX 35 | # View current config: 36 | upterm config view 37 | 38 | # View and save as new config: 39 | upterm config view > "$(upterm config path)" 40 | .EE 41 | 42 | 43 | .SH SEE ALSO 44 | \fBupterm-config(1)\fP 45 | 46 | 47 | .SH HISTORY 48 | 29-Nov-2025 Auto generated by spf13/cobra 49 | -------------------------------------------------------------------------------- /charts/uptermd/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "upterm.fullname" . }} 6 | labels: 7 | {{- include "upterm.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "upterm.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | target: 21 | type: Utilization 22 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 23 | {{- end }} 24 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | target: 29 | type: Utilization 30 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 31 | {{- end }} 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /docs/upterm_config_edit.md: -------------------------------------------------------------------------------- 1 | ## upterm config edit 2 | 3 | Edit the config file 4 | 5 | ### Synopsis 6 | 7 | Edit the config file in your default editor. 8 | 9 | Config file: /home/user/.config/upterm/config.yaml 10 | 11 | This command opens the config file in your editor (determined by $VISUAL, $EDITOR, 12 | or a sensible default). If the config file doesn't exist, it creates a template 13 | with example settings and comments. 14 | 15 | The config directory is created automatically if it doesn't exist. 16 | 17 | ``` 18 | upterm config edit [flags] 19 | ``` 20 | 21 | ### Examples 22 | 23 | ``` 24 | # Edit config file: 25 | upterm config edit 26 | 27 | # Use a specific editor: 28 | EDITOR=nano upterm config edit 29 | ``` 30 | 31 | ### Options 32 | 33 | ``` 34 | -h, --help help for edit 35 | ``` 36 | 37 | ### Options inherited from parent commands 38 | 39 | ``` 40 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 41 | ``` 42 | 43 | ### SEE ALSO 44 | 45 | * [upterm config](upterm_config.md) - Manage upterm configuration 46 | 47 | ###### Auto generated by spf13/cobra on 29-Nov-2025 48 | -------------------------------------------------------------------------------- /host/host_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package host 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/oklog/run" 12 | ) 13 | 14 | // setupSignalHandler configures OS signal handling for Windows. 15 | // Only listens for SIGTERM (console close, logoff, shutdown) for graceful shutdown. 16 | // Explicitly ignores os.Interrupt (Ctrl+C, Ctrl+Break) to prevent upterm from dying 17 | // when SSH clients send Ctrl+C to child processes via ConPTY. 18 | func setupSignalHandler(g *run.Group, ctx context.Context) { 19 | // Listen for SIGTERM for graceful shutdown 20 | g.Add(run.SignalHandler(ctx, syscall.SIGTERM)) 21 | 22 | // Consume and ignore os.Interrupt (Ctrl+C, Ctrl+Break) 23 | // This prevents the default OS behavior (process termination) while allowing 24 | // child processes in ConPTY to receive these signals normally. 25 | { 26 | sigCh := make(chan os.Signal, 1) 27 | signal.Notify(sigCh, os.Interrupt) 28 | g.Add(func() error { 29 | for range sigCh { 30 | // Consume and ignore - prevents upterm from being killed 31 | } 32 | return nil 33 | }, func(err error) { 34 | signal.Stop(sigCh) 35 | close(sigCh) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-config-edit.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-config-edit - Edit the config file 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm config edit [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Edit the config file in your default editor. 14 | 15 | .PP 16 | Config file: /home/user/.config/upterm/config.yaml 17 | 18 | .PP 19 | This command opens the config file in your editor (determined by $VISUAL, $EDITOR, 20 | or a sensible default). If the config file doesn't exist, it creates a template 21 | with example settings and comments. 22 | 23 | .PP 24 | The config directory is created automatically if it doesn't exist. 25 | 26 | 27 | .SH OPTIONS 28 | \fB-h\fP, \fB--help\fP[=false] 29 | help for edit 30 | 31 | 32 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 33 | \fB--debug\fP[=false] 34 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 35 | 36 | 37 | .SH EXAMPLE 38 | .EX 39 | # Edit config file: 40 | upterm config edit 41 | 42 | # Use a specific editor: 43 | EDITOR=nano upterm config edit 44 | .EE 45 | 46 | 47 | .SH SEE ALSO 48 | \fBupterm-config(1)\fP 49 | 50 | 51 | .SH HISTORY 52 | 29-Nov-2025 Auto generated by spf13/cobra 53 | -------------------------------------------------------------------------------- /io/writer_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func Test_MultiWriter(t *testing.T) { 12 | assert := assert.New(t) 13 | 14 | w1 := bytes.NewBuffer(nil) 15 | w := NewMultiWriter(1, w1) 16 | 17 | r := bytes.NewBufferString("hello1") 18 | _, _ = io.Copy(w, r) 19 | 20 | assert.Equal("hello1", w1.String()) 21 | 22 | // append w2 23 | r = bytes.NewBufferString("hello2") 24 | w2 := bytes.NewBuffer(nil) 25 | _ = w.Append(w2) 26 | _, _ = io.Copy(w, r) 27 | 28 | assert.Equal("hello1hello2", w1.String()) 29 | assert.Equal("hello1hello2", w2.String()) 30 | 31 | // append w3 32 | r = bytes.NewBufferString("hello3") 33 | w3 := bytes.NewBuffer(nil) 34 | _ = w.Append(w3) 35 | _, _ = io.Copy(w, r) 36 | 37 | assert.Equal("hello1hello2hello3", w1.String()) 38 | assert.Equal("hello1hello2hello3", w2.String()) 39 | assert.Equal("hello2hello3", w3.String()) 40 | 41 | // remove w2 42 | r = bytes.NewBufferString("hello4") 43 | w.Remove(w2) 44 | _, _ = io.Copy(w, r) 45 | 46 | assert.Equal("hello1hello2hello3hello4", w1.String()) 47 | assert.Equal("hello1hello2hello3", w2.String()) 48 | assert.Equal("hello2hello3hello4", w3.String()) 49 | } 50 | -------------------------------------------------------------------------------- /host/internal/pty.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "io" 4 | 5 | // PTY represents a pseudo-terminal abstraction that works across platforms. 6 | // On Unix, it wraps a traditional PTY created via creack/pty. 7 | // On Windows, it wraps a ConPTY (Console Pseudo Terminal). 8 | // 9 | // The interface provides a common abstraction for: 10 | // - Reading/writing terminal I/O (via io.ReadWriteCloser) 11 | // - Resizing the terminal window 12 | // - Managing process lifecycle (Wait/Kill) 13 | // 14 | // Platform-specific implementations: 15 | // - Unix: see pty_unix.go 16 | // - Windows: see pty_windows.go 17 | type PTY interface { 18 | io.ReadWriteCloser 19 | 20 | // Setsize changes the terminal dimensions. 21 | // On Unix, this sends a SIGWINCH to the slave process. 22 | // On Windows, this resizes the ConPTY buffer. 23 | Setsize(h, w int) error 24 | 25 | // Wait waits for the process associated with this PTY to exit. 26 | // On Unix, this delegates to exec.Cmd.Wait(). 27 | // On Windows, this waits on the process handle. 28 | Wait() error 29 | 30 | // Kill terminates the process associated with this PTY. 31 | // On Unix, this delegates to exec.Cmd.Process.Kill(). 32 | // On Windows, this calls TerminateProcess on the handle. 33 | Kill() error 34 | } 35 | -------------------------------------------------------------------------------- /charts/uptermd/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.websocket.enabled }} 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: {{ include "upterm.fullname" . }} 6 | labels: 7 | {{- include "upterm.labels" . | nindent 4 }} 8 | annotations: 9 | kubernetes.io/ingress.class: {{ .Values.websocket.ingress_nginx_ingress_class }} 10 | nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" 11 | nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" 12 | nginx.ingress.kubernetes.io/limit-connections: "4" 13 | nginx.ingress.kubernetes.io/limit-rps: "5" 14 | cert-manager.io/issuer: {{ include "upterm.fullname" . }}-letsencrypt 15 | {{- with .Values.websocket.ingress.annotations }} 16 | {{- toYaml . | nindent 4 }} 17 | {{- end }} 18 | spec: 19 | tls: 20 | - hosts: 21 | - {{ .Values.hostname }} 22 | secretName: {{ .Values.hostname | replace "." "-" }}-tls 23 | rules: 24 | - host: {{ .Values.hostname }} 25 | http: 26 | paths: 27 | - path: / 28 | pathType: Prefix 29 | backend: 30 | service: 31 | name: {{ include "upterm.fullname" . }} 32 | port: 33 | number: 80 34 | {{- end }} 35 | -------------------------------------------------------------------------------- /charts/uptermd/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | Host a terminal session by running these commands: 2 | {{- if contains "NodePort" .Values.service.type }} 3 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 4 | upterm host --server ssh://$NODE_IP:22 -- bash 5 | {{- else if contains "LoadBalancer" .Values.service.type }} 6 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 7 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "upterm.fullname" . }}' 8 | 9 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "upterm.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 10 | upterm host --server ssh://$SERVICE_IP:22 -- bash 11 | {{- else if contains "ClusterIP" .Values.service.type }} 12 | 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}") 13 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 2222:22 14 | upterm host --server ssh://localhost:2222 -- bash 15 | {{- end }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | permissions: 7 | contents: write 8 | packages: write 9 | jobs: 10 | build-and-release: 11 | name: Release binaries and Docker images 12 | uses: ./.github/workflows/build-and-release.yaml 13 | with: 14 | snapshot: false 15 | docker_repo: ghcr.io/owenthereal/upterm/uptermd 16 | secrets: inherit 17 | deploy: 18 | name: Deploy app 19 | runs-on: ubuntu-latest 20 | needs: [build-and-release] 21 | steps: 22 | - uses: actions/checkout@v6 23 | - uses: superfly/flyctl-actions/setup-flyctl@master 24 | - name: Get version from tag 25 | id: version 26 | run: | 27 | VERSION=${GITHUB_REF#refs/tags/v} 28 | echo "version=$VERSION" >> $GITHUB_OUTPUT 29 | echo "git_commit=$GITHUB_SHA" >> $GITHUB_OUTPUT 30 | echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT 31 | - name: Deploy to Fly.io 32 | run: | 33 | flyctl deploy --remote-only \ 34 | --build-arg VERSION=${{ steps.version.outputs.version }} \ 35 | --build-arg GIT_COMMIT=${{ steps.version.outputs.git_commit }} \ 36 | --build-arg BUILD_DATE=${{ steps.version.outputs.build_date }} 37 | env: 38 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 39 | -------------------------------------------------------------------------------- /Dockerfile.uptermd: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # Build stage - builds from source (used by Fly deployment) 4 | FROM golang:latest AS builder 5 | 6 | ARG TARGETOS 7 | ARG TARGETARCH 8 | ARG VERSION=0.0.0+dev 9 | ARG GIT_COMMIT=unknown 10 | ARG BUILD_DATE=unknown 11 | 12 | WORKDIR /src 13 | ENV CGO_ENABLED=0 14 | RUN --mount=target=. \ 15 | --mount=type=cache,target=/root/.cache/go-build \ 16 | --mount=type=cache,target=/go/pkg \ 17 | GOOS=$TARGETOS GOARCH=$TARGETARCH go install \ 18 | -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}" \ 19 | ./cmd/... 20 | 21 | # Base runtime stage 22 | FROM gcr.io/distroless/static:nonroot AS base 23 | 24 | WORKDIR /app 25 | ENV PATH="/app:${PATH}" 26 | 27 | # sshd ws & prometheus 28 | EXPOSE 2222 8080 9090 29 | 30 | # Fly deployment stage (builds from source) 31 | FROM base AS uptermd-fly 32 | COPY --from=builder /go/bin/uptermd /go/bin/uptermd-fly /app/ 33 | ENTRYPOINT ["uptermd-fly"] 34 | 35 | # Pre-built binary stage (used by GoReleaser) 36 | FROM base AS pre-built-binary 37 | COPY uptermd /app/ 38 | ENTRYPOINT ["uptermd"] 39 | 40 | # Default stage 41 | FROM base AS uptermd 42 | COPY --from=builder /go/bin/uptermd /app/ 43 | ENTRYPOINT ["uptermd"] 44 | -------------------------------------------------------------------------------- /terraform/digitalocean/variables.tf: -------------------------------------------------------------------------------- 1 | ### Digital Ocean ### 2 | variable "do_token" {} 3 | 4 | variable "do_region" { 5 | type = string 6 | default = "sfo2" 7 | } 8 | 9 | variable "do_k8s_name" { 10 | type = string 11 | default = "upterm-cluster" 12 | } 13 | 14 | variable "do_min_nodes" { 15 | type = number 16 | default = 1 17 | } 18 | 19 | variable "do_max_nodes" { 20 | type = number 21 | default = 3 22 | } 23 | 24 | variable "do_node_size" { 25 | type = string 26 | default = "s-2vcpu-4gb" 27 | } 28 | 29 | variable "write_kubeconfig" { 30 | type = bool 31 | default = false 32 | } 33 | 34 | variable "kubeconfig_path" { 35 | type = string 36 | default = "~/.kube/config" 37 | } 38 | ### Digital Ocean ### 39 | 40 | ### Charts ### 41 | variable "wait_for_k8s_resources" { 42 | type = bool 43 | default = true 44 | } 45 | 46 | variable "uptermd_host" { 47 | type = string 48 | } 49 | 50 | variable "uptermd_acme_email" { 51 | type = string 52 | } 53 | 54 | variable "uptermd_host_keys" { 55 | type = map(string) # { filename=content } 56 | description = "Host keys in the format of {\"rsa_key.pub\"=\"...\", \"rsa_key\"=\"...\"}" 57 | } 58 | 59 | variable "uptermd_helm_repo" { 60 | type = string 61 | default = "https://upterm.dev" 62 | description = "Configurable for testing purpose" 63 | } 64 | ### Charts ### 65 | -------------------------------------------------------------------------------- /docs/upterm_session_current.md: -------------------------------------------------------------------------------- 1 | ## upterm session current 2 | 3 | Display the current terminal session 4 | 5 | ### Synopsis 6 | 7 | Display the current terminal session. 8 | 9 | By default, reads the admin socket path from $UPTERM_ADMIN_SOCKET (automatically set 10 | when you run 'upterm host'). 11 | 12 | Sockets are stored in: /run/user/1000/upterm 13 | 14 | Follows the XDG Base Directory Specification with fallback to $HOME/.upterm 15 | in constrained environments where XDG directories are unavailable. 16 | 17 | ``` 18 | upterm session current [flags] 19 | ``` 20 | 21 | ### Examples 22 | 23 | ``` 24 | # Display the active session as defined in $UPTERM_ADMIN_SOCKET: 25 | upterm session current 26 | 27 | # Display the session with a custom admin socket path: 28 | upterm session current --admin-socket ADMIN_SOCKET_PATH 29 | ``` 30 | 31 | ### Options 32 | 33 | ``` 34 | --admin-socket string Admin socket path (required). 35 | -h, --help help for current 36 | --hide-client-ip Hide client IP addresses from output (auto-enabled in CI environments). 37 | ``` 38 | 39 | ### Options inherited from parent commands 40 | 41 | ``` 42 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 43 | ``` 44 | 45 | ### SEE ALSO 46 | 47 | * [upterm session](upterm_session.md) - Display and manage terminal sessions 48 | 49 | ###### Auto generated by spf13/cobra on 29-Nov-2025 50 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "upterm" 2 | kill_signal = "SIGINT" 3 | kill_timeout = "5s" 4 | 5 | [build] 6 | dockerfile = "Dockerfile.uptermd" 7 | build-target = "uptermd-fly" 8 | 9 | [metrics] 10 | port = 9091 11 | path = "/metrics" 12 | 13 | [experimental] 14 | entrypoint = ["uptermd-fly"] 15 | 16 | [vm] 17 | cpu_kind = "shared" 18 | cpus = 1 19 | memory_mb = 256 20 | 21 | [[services]] 22 | protocol = "tcp" 23 | internal_port = 2222 24 | auto_stop_machines = false 25 | auto_start_machines = true 26 | min_machines_running = 3 27 | processes = ["app"] 28 | 29 | [[services.ports]] 30 | port = 22 31 | handlers = ["proxy_proto"] 32 | proxy_proto_options = { version = "v2" } 33 | 34 | [services.concurrency] 35 | type = "connections" 36 | hard_limit = 2500 37 | soft_limit = 2000 38 | 39 | [[services.tcp_checks]] 40 | interval = "15s" 41 | timeout = "2s" 42 | grace_period = "5s" 43 | restart_limit = 3 44 | 45 | [[services]] 46 | protocol = "tcp" 47 | internal_port = 8080 48 | auto_stop_machines = false 49 | auto_start_machines = true 50 | min_machines_running = 3 51 | processes = ["app"] 52 | 53 | [[services.ports]] 54 | port = 80 55 | handlers = ["http"] 56 | force_https = true 57 | 58 | [[services.ports]] 59 | port = 443 60 | handlers = ["tls", "http"] 61 | [services.concurrency] 62 | type = "connections" 63 | hard_limit = 2500 64 | soft_limit = 2000 65 | 66 | [[services.http_checks]] 67 | interval = "30s" 68 | timeout = "5s" 69 | grace_period = "10s" 70 | restart_limit = 3 71 | path = "/health" 72 | protocol = "http" 73 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-session-current.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-session-current - Display the current terminal session 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm session current [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Display the current terminal session. 14 | 15 | .PP 16 | By default, reads the admin socket path from $UPTERM_ADMIN_SOCKET (automatically set 17 | when you run 'upterm host'). 18 | 19 | .PP 20 | Sockets are stored in: /run/user/1000/upterm 21 | 22 | .PP 23 | Follows the XDG Base Directory Specification with fallback to $HOME/.upterm 24 | in constrained environments where XDG directories are unavailable. 25 | 26 | 27 | .SH OPTIONS 28 | \fB--admin-socket\fP="" 29 | Admin socket path (required). 30 | 31 | .PP 32 | \fB-h\fP, \fB--help\fP[=false] 33 | help for current 34 | 35 | .PP 36 | \fB--hide-client-ip\fP[=false] 37 | Hide client IP addresses from output (auto-enabled in CI environments). 38 | 39 | 40 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 41 | \fB--debug\fP[=false] 42 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 43 | 44 | 45 | .SH EXAMPLE 46 | .EX 47 | # Display the active session as defined in $UPTERM_ADMIN_SOCKET: 48 | upterm session current 49 | 50 | # Display the session with a custom admin socket path: 51 | upterm session current --admin-socket ADMIN_SOCKET_PATH 52 | .EE 53 | 54 | 55 | .SH SEE ALSO 56 | \fBupterm-session(1)\fP 57 | 58 | 59 | .SH HISTORY 60 | 29-Nov-2025 Auto generated by spf13/cobra 61 | -------------------------------------------------------------------------------- /cmd/upterm/command/host_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func Test_parseURL(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | url string 13 | wantScheme string 14 | wantHost string 15 | wantPort string 16 | }{ 17 | { 18 | name: "port 443", 19 | url: "wss://foo.com:443", 20 | wantScheme: "wss", 21 | wantHost: "foo.com", 22 | wantPort: "443", 23 | }, 24 | { 25 | name: "port 80", 26 | url: "http://foo.com:80", 27 | wantScheme: "http", 28 | wantHost: "foo.com", 29 | wantPort: "80", 30 | }, 31 | { 32 | name: "port 22", 33 | url: "ssh://foo.com:22", 34 | wantScheme: "ssh", 35 | wantHost: "foo.com", 36 | wantPort: "22", 37 | }, 38 | { 39 | name: "no port", 40 | url: "wss://foo.com", 41 | wantScheme: "wss", 42 | wantHost: "foo.com", 43 | wantPort: "443", 44 | }, 45 | } 46 | 47 | for _, c := range cases { 48 | cc := c 49 | t.Run(cc.name, func(t *testing.T) { 50 | t.Parallel() 51 | 52 | _, scheme, host, port, err := parseURL(cc.url) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | if diff := cmp.Diff(cc.wantScheme, scheme); diff != "" { 58 | t.Fatal(diff) 59 | } 60 | 61 | if diff := cmp.Diff(cc.wantHost, host); diff != "" { 62 | t.Fatal(diff) 63 | } 64 | 65 | if diff := cmp.Diff(cc.wantPort, port); diff != "" { 66 | t.Fatal(diff) 67 | } 68 | }) 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /host/internal/command_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package internal 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "time" 9 | 10 | "github.com/oklog/run" 11 | "github.com/olebedev/emitter" 12 | ) 13 | 14 | // setupTerminalResize polls for terminal size changes on Windows 15 | // Windows doesn't have SIGWINCH signals like Unix, so we poll for terminal size changes 16 | // Note: Initial size is already set in startPty 17 | func (c *command) setupTerminalResize(g *run.Group, stdin *os.File, ptmx PTY, eventEmitter *emitter.Emitter) { 18 | // Get the initial size to track changes 19 | h, w, err := getPtysize(stdin) 20 | if err != nil { 21 | // If we can't get the size, skip resize monitoring 22 | return 23 | } 24 | 25 | tee := terminalEventEmitter{eventEmitter} 26 | // Track the last known size for comparison 27 | lastH, lastW := h, w 28 | 29 | // Poll for terminal size changes 30 | ctx, cancel := context.WithCancel(c.ctx) 31 | g.Add(func() error { 32 | ticker := time.NewTicker(500 * time.Millisecond) 33 | defer ticker.Stop() 34 | 35 | for { 36 | select { 37 | case <-ctx.Done(): 38 | return ctx.Err() 39 | case <-ticker.C: 40 | h, w, err := getPtysize(stdin) 41 | if err != nil { 42 | // Can't get size, skip this check 43 | continue 44 | } 45 | 46 | // Only notify if size actually changed 47 | if h != lastH || w != lastW { 48 | lastH, lastW = h, w 49 | tee.TerminalWindowChanged("local", ptmx, w, h) 50 | } 51 | } 52 | } 53 | }, func(err error) { 54 | tee.TerminalDetached("local", ptmx) 55 | cancel() 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /cmd/gendoc/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/owenthereal/upterm/cmd/upterm/command" 7 | "github.com/owenthereal/upterm/internal/logging" 8 | "github.com/owenthereal/upterm/internal/version" 9 | "github.com/spf13/cobra/doc" 10 | ) 11 | 12 | func main() { 13 | logger := logging.Must(logging.Console()).With("component", "gendoc") 14 | defer func() { 15 | _ = logger.Close() 16 | }() 17 | 18 | // Note: XDG environment variables should be set externally before running this command 19 | // to generate docs with generic paths instead of machine-specific paths. 20 | // See Makefile 'docs' target for proper environment variable setup. 21 | rootCmd := command.Root() 22 | 23 | if err := doc.GenMarkdownTree(rootCmd, "./docs"); err != nil { 24 | logger.Error("failed generating markdown docs", "error", err) 25 | os.Exit(1) 26 | } 27 | 28 | header := &doc.GenManHeader{ 29 | Title: "UPTERM", 30 | Section: "1", 31 | Source: "Upterm " + version.String(), 32 | Manual: "Upterm Manual", 33 | } 34 | if err := doc.GenManTree(rootCmd, header, "./etc/man/man1"); err != nil { 35 | logger.Error("failed generating man pages", "error", err) 36 | os.Exit(1) 37 | } 38 | 39 | if err := rootCmd.GenBashCompletionFile("./etc/completion/upterm.bash_completion.sh"); err != nil { 40 | logger.Error("failed generating bash completion", "error", err) 41 | os.Exit(1) 42 | } 43 | if err := rootCmd.GenZshCompletionFile("./etc/completion/upterm.zsh_completion"); err != nil { 44 | logger.Error("failed generating zsh completion", "error", err) 45 | os.Exit(1) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /host/internal/adminserver.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "sync" 7 | 8 | "github.com/owenthereal/upterm/host/api" 9 | "google.golang.org/grpc" 10 | ) 11 | 12 | type AdminServer struct { 13 | Session *api.GetSessionResponse 14 | ClientRepo *ClientRepo 15 | srv *grpc.Server 16 | sync.Mutex 17 | } 18 | 19 | func (s *AdminServer) Serve(ctx context.Context, sock string) error { 20 | ln, err := net.Listen("unix", sock) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | s.Lock() 26 | s.srv = grpc.NewServer() 27 | api.RegisterAdminServiceServer(s.srv, &adminServiceServer{ 28 | Session: s.Session, 29 | ClientRepo: s.ClientRepo, 30 | }) 31 | s.Unlock() 32 | 33 | return s.srv.Serve(ln) 34 | } 35 | 36 | func (s *AdminServer) Shutdown(ctx context.Context) error { 37 | s.Lock() 38 | defer s.Unlock() 39 | 40 | if s.srv != nil { 41 | s.srv.GracefulStop() 42 | } 43 | 44 | return nil 45 | } 46 | 47 | type adminServiceServer struct { 48 | Session *api.GetSessionResponse 49 | ClientRepo *ClientRepo 50 | } 51 | 52 | func (s *adminServiceServer) GetSession(ctx context.Context, in *api.GetSessionRequest) (*api.GetSessionResponse, error) { 53 | return &api.GetSessionResponse{ 54 | SessionId: s.Session.SessionId, 55 | Host: s.Session.Host, 56 | NodeAddr: s.Session.NodeAddr, 57 | SshUser: s.Session.SshUser, 58 | Command: s.Session.Command, 59 | ForceCommand: s.Session.ForceCommand, 60 | AuthorizedKeys: s.Session.AuthorizedKeys, 61 | ConnectedClients: s.ClientRepo.Clients(), 62 | }, nil 63 | } 64 | -------------------------------------------------------------------------------- /cmd/uptermd-fly/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/owenthereal/upterm/cmd/uptermd/command" 9 | ) 10 | 11 | func main() { 12 | flyAppName := os.Getenv("FLY_APP_NAME") 13 | if flyAppName == "" { 14 | slog.Error("FLY_APP_NAME is not set") 15 | os.Exit(1) 16 | } 17 | 18 | flyMachineID := os.Getenv("FLY_MACHINE_ID") 19 | if flyMachineID == "" { 20 | slog.Error("FLY_MACHINE_ID is not set") 21 | os.Exit(1) 22 | } 23 | 24 | config := map[string]any{ 25 | "UPTERMD_SSH_ADDR": "[::]:2222", 26 | "UPTERMD_WS_ADDR": "[::]:8080", 27 | "UPTERMD_NODE_ADDR": fmt.Sprintf("%s.vm.%s.internal:2222", flyMachineID, flyAppName), 28 | "UPTERMD_SSH_PROXY_PROTOCOL": "true", 29 | "UPTERMD_METRIC_ADDR": "[::]:9091", 30 | } 31 | 32 | flyConsulURL := os.Getenv("FLY_CONSUL_URL") 33 | if flyConsulURL != "" { 34 | config["UPTERMD_ROUTING"] = "consul" 35 | config["UPTERMD_CONSUL_URL"] = flyConsulURL 36 | config["UPTERMD_CONSUL_SESSION_TTL"] = "1h" 37 | slog.Info("Using Consul routing for multi-machine deployment") 38 | } else { 39 | config["UPTERMD_ROUTING"] = "embedded" 40 | slog.Info("Using embedded routing for single-machine deployment") 41 | } 42 | 43 | for key, value := range config { 44 | if err := os.Setenv(key, fmt.Sprintf("%v", value)); err != nil { 45 | slog.Error("failed to set environment variable", "key", key, "error", err) 46 | os.Exit(1) 47 | } 48 | } 49 | 50 | slog.Info("Starting uptermd on Fly.io", "config", config) 51 | if err := command.Root().Execute(); err != nil { 52 | slog.Error("command execution failed", "error", err) 53 | os.Exit(1) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /memlistener/memlistener_test.go: -------------------------------------------------------------------------------- 1 | package memlistener 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func Test_MemListener_Listen(t *testing.T) { 11 | t.Parallel() 12 | 13 | l := New() 14 | 15 | sln, err := l.Listen("mem", "path_foo") 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | defer func() { 20 | _ = sln.Close() 21 | }() 22 | 23 | // error on listener with the same address 24 | _, err = l.Listen("mem", "path_foo") 25 | want, got := errListenerAlreadyExist{"path_foo"}, err 26 | if !strings.Contains(got.Error(), want.Error()) { 27 | t.Fatalf("got doesn't contain want (-want +got):\n%s", cmp.Diff(want.Error(), got.Error())) 28 | } 29 | } 30 | 31 | func Test_MemListener_Dial(t *testing.T) { 32 | t.Parallel() 33 | 34 | l := New() 35 | 36 | _, err := l.Dial("mem", "not_exist") 37 | want, got := errListenerNotFound{"not_exist"}, err 38 | if !strings.Contains(got.Error(), want.Error()) { 39 | t.Fatalf("got doesn't contain want (-want +got):\n%s", cmp.Diff(want.Error(), got.Error())) 40 | } 41 | } 42 | 43 | func Test_MemListener_RemoveListener(t *testing.T) { 44 | t.Parallel() 45 | 46 | l := New() 47 | 48 | sln, err := l.Listen("mem", "path_bar") 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | sln2, ok := l.listeners.Load("path_bar") 54 | if !ok { 55 | t.Fatal("listener path not found") 56 | } 57 | 58 | if want, got := sln, sln2; want != got { 59 | t.Fatalf("listeners not equal: want=%v, got=%v", want, got) 60 | } 61 | 62 | if err := sln.Close(); err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | _, ok = l.listeners.Load("path_bar") 67 | if ok { 68 | t.Fatal("listener path shouldn't be found") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cmd/upterm/command/proxy.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/oklog/run" 11 | uio "github.com/owenthereal/upterm/io" 12 | "github.com/owenthereal/upterm/ws" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func proxyCmd() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "proxy", 19 | Short: "Proxy a terminal session via WebSocket", 20 | Long: "Proxy a terminal session via WebSocket, to be used alongside SSH ProxyCommand.", 21 | Example: ` # Host shares a session running $SHELL over WebSocket: 22 | upterm host --server wss://uptermd.upterm.dev -- YOUR_COMMAND 23 | 24 | # Client connects to the host session via WebSocket: 25 | ssh -o ProxyCommand='upterm proxy wss://TOKEN@uptermd.upterm.dev' TOKEN:uptermd.uptermd.dev:443`, 26 | RunE: proxyRunE, 27 | } 28 | 29 | return cmd 30 | } 31 | 32 | func proxyRunE(c *cobra.Command, args []string) error { 33 | if len(args) == 0 { 34 | return fmt.Errorf("missing WebSocket url") 35 | } 36 | 37 | u, err := url.Parse(args[0]) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | conn, err := ws.NewWSConn(u, true) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | ctx, cancel := context.WithCancel(context.Background()) 48 | defer cancel() 49 | 50 | var g run.Group 51 | { 52 | g.Add(func() error { 53 | _, err := io.Copy(conn, uio.NewContextReader(ctx, os.Stdin)) 54 | return err 55 | }, func(err error) { 56 | _ = conn.Close() 57 | cancel() 58 | }) 59 | } 60 | { 61 | g.Add(func() error { 62 | _, err := io.Copy(os.Stdout, uio.NewContextReader(ctx, conn)) 63 | return err 64 | }, func(err error) { 65 | _ = conn.Close() 66 | cancel() 67 | }) 68 | } 69 | 70 | return g.Run() 71 | } 72 | -------------------------------------------------------------------------------- /internal/testhelpers/consul.go: -------------------------------------------------------------------------------- 1 | package testhelpers 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | "os" 7 | "time" 8 | 9 | "github.com/hashicorp/consul/api" 10 | ) 11 | 12 | const ( 13 | // ConsulHealthCheckTimeout is the timeout for Consul health checks 14 | ConsulHealthCheckTimeout = 2 * time.Second 15 | ) 16 | 17 | // IsConsulAvailable checks if Consul is running and accessible with timeout handling 18 | func IsConsulAvailable() bool { 19 | config := api.DefaultConfig() 20 | consulURLStr := ConsulURL() 21 | u, err := url.Parse(consulURLStr) 22 | if err != nil { 23 | return false 24 | } 25 | config.Address = u.Host 26 | 27 | client, err := api.NewClient(config) 28 | if err != nil { 29 | return false 30 | } 31 | 32 | // Try to get leader with timeout - simple health check 33 | ctx, cancel := context.WithTimeout(context.Background(), ConsulHealthCheckTimeout) 34 | defer cancel() 35 | 36 | done := make(chan bool, 1) 37 | go func() { 38 | _, err = client.Status().Leader() 39 | done <- err == nil 40 | }() 41 | 42 | select { 43 | case result := <-done: 44 | return result 45 | case <-ctx.Done(): 46 | return false 47 | } 48 | } 49 | 50 | // ConsulURL returns the Consul URL from environment or default 51 | func ConsulURL() string { 52 | addr := os.Getenv("CONSUL_URL") 53 | if addr == "" { 54 | addr = "http://localhost:8500" 55 | } 56 | return addr 57 | } 58 | 59 | // ConsulClient creates a new Consul API client 60 | func ConsulClient() (*api.Client, error) { 61 | config := api.DefaultConfig() 62 | consulURL, err := url.Parse(ConsulURL()) 63 | if err != nil { 64 | return nil, err 65 | } 66 | config.Address = consulURL.Host 67 | 68 | client, err := api.NewClient(config) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return client, nil 73 | } 74 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash -o pipefail 2 | 3 | BIN_DIR ?= $(CURDIR)/bin 4 | export PATH := $(BIN_DIR):$(PATH) 5 | 6 | .PHONY: tools 7 | tools: 8 | rm -rf $(BIN_DIR) && mkdir -p $(BIN_DIR) 9 | # goreleaser 10 | GOBIN=$(BIN_DIR) go install github.com/goreleaser/goreleaser@latest 11 | 12 | .PHONY: generate 13 | generate: proto 14 | 15 | .PHONY: docs 16 | docs: 17 | rm -rf docs && mkdir docs 18 | rm -rf etc && mkdir -p etc/man/man1 && mkdir -p etc/completion 19 | 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 20 | 21 | .PHONY: proto 22 | proto: 23 | docker run -v $(CURDIR)/server:/defs namely/protoc-all -f server.proto -l go --go-source-relative -o . 24 | docker run -v $(CURDIR)/host/api:/defs namely/protoc-all -f api.proto -l go --go-source-relative -o . 25 | 26 | .PHONY: build 27 | build: 28 | go build -o $(BIN_DIR)/upterm ./cmd/upterm 29 | go build -o $(BIN_DIR)/uptermd ./cmd/uptermd 30 | go build -o $(BIN_DIR)/uptermd-fly ./cmd/uptermd-fly 31 | 32 | .PHONY: install 33 | install: 34 | go install ./cmd/... 35 | 36 | TAG ?= latest 37 | REPO ?= ghcr.io/owenthereal/upterm/uptermd 38 | DOCKER_BUILD_FLAGS ?= --load 39 | .PHONY: docker_build 40 | docker_build: 41 | docker buildx build -t $(REPO):$(TAG) -f Dockerfile.uptermd $(DOCKER_BUILD_FLAGS) . 42 | 43 | GO_TEST_FLAGS ?= "" 44 | .PHONY: test 45 | test: 46 | go test ./... -timeout=120s -coverprofile=c.out -covermode=atomic -count=1 -race -v $(GO_TEST_FLAGS) 47 | 48 | .PHONY: vet 49 | vet: 50 | docker run --rm -v $(CURDIR):/app:z -w /app golangci/golangci-lint:latest golangci-lint run -v --timeout 15m --fix 51 | 52 | DOCKER_REPO ?= ghcr.io/owenthereal/upterm/uptermd 53 | .PHONY: goreleaser 54 | goreleaser: 55 | DOCKER_REPO=$(DOCKER_REPO) goreleaser release --clean --snapshot --skip=publish 56 | -------------------------------------------------------------------------------- /charts/uptermd/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for upterm. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: ghcr.io/owenthereal/upterm/uptermd 9 | pullPolicy: Always 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: latest 12 | 13 | imagePullSecrets: [] 14 | nameOverride: "" 15 | fullnameOverride: "" 16 | 17 | debug: false 18 | 19 | serviceAccount: 20 | # Specifies whether a service account should be created 21 | create: true 22 | # Annotations to add to the service account 23 | annotations: {} 24 | # The name of the service account to use. 25 | # If not set and create is true, a name is generated using the fullname template 26 | name: "" 27 | 28 | podAnnotations: {} 29 | 30 | podSecurityContext: {} 31 | # fsGroup: 2000 32 | 33 | securityContext: {} 34 | # capabilities: 35 | # drop: 36 | # - ALL 37 | # readOnlyRootFilesystem: true 38 | # runAsNonRoot: true 39 | # runAsUser: 1000 40 | 41 | service: 42 | type: ClusterIP 43 | # Set to LoadBalancer to accept traffic from outside the cluster 44 | # type: LoadBalancer 45 | annotations: {} 46 | 47 | resources: 48 | limits: 49 | cpu: 100m 50 | memory: 512Mi 51 | requests: 52 | cpu: 100m 53 | memory: 512Mi 54 | 55 | autoscaling: 56 | enabled: false 57 | minReplicas: 1 58 | maxReplicas: 10 59 | targetCPUUtilizationPercentage: 80 60 | targetMemoryUtilizationPercentage: 80 61 | 62 | nodeSelector: {} 63 | 64 | tolerations: [] 65 | 66 | affinity: {} 67 | 68 | host_keys: {} 69 | 70 | hostname: my-upterm-host 71 | 72 | # Require ingress-nginx & cert-manager 73 | websocket: 74 | enabled: false 75 | cert_manager_acme_email: your_email 76 | ingress_nginx_ingress_class: nginx 77 | ingress: 78 | annotations: {} 79 | -------------------------------------------------------------------------------- /utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestXDGDirWithFallback(t *testing.T) { 13 | // Get the actual home directory for fallback tests 14 | home, err := os.UserHomeDir() 15 | require.NoError(t, err, "failed to get user home dir") 16 | 17 | xdgPath := t.TempDir() 18 | 19 | tests := []struct { 20 | name string 21 | envVar string 22 | envMap map[string]string 23 | xdgPath string 24 | want string 25 | }{ 26 | { 27 | name: "respects explicitly set env var", 28 | envVar: "XDG_RUNTIME_DIR", 29 | envMap: map[string]string{"XDG_RUNTIME_DIR": filepath.Join("/tmp", "custom-runtime")}, 30 | xdgPath: filepath.Join("/run", "user", "1000"), // This would be the default 31 | want: filepath.Join("/tmp", "custom-runtime", "upterm"), 32 | }, 33 | { 34 | name: "uses xdg path when it exists", 35 | envVar: "XDG_RUNTIME_DIR", 36 | envMap: map[string]string{}, 37 | xdgPath: xdgPath, 38 | want: filepath.Join(xdgPath, "upterm"), 39 | }, 40 | { 41 | name: "falls back to HOME when xdg path doesn't exist", 42 | envVar: "XDG_RUNTIME_DIR", 43 | envMap: map[string]string{}, 44 | xdgPath: filepath.Join("/nonexistent", "path"), 45 | want: filepath.Join(home, ".upterm"), 46 | }, 47 | { 48 | name: "falls back to HOME for all directory types", 49 | envVar: "XDG_STATE_HOME", 50 | envMap: map[string]string{}, 51 | xdgPath: filepath.Join("/nonexistent", "path"), 52 | want: filepath.Join(home, ".upterm"), 53 | }, 54 | } 55 | 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | // Create a mock env getter - no need for os.Setenv! 59 | getenv := func(key string) string { 60 | return tt.envMap[key] 61 | } 62 | 63 | got := xdgDirWithFallbackEnv(tt.envVar, tt.xdgPath, getenv) 64 | assert.Equal(t, tt.want, got) 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /cmd/upterm/command/privacy.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import "os" 4 | 5 | var ( 6 | flagHideClientIP bool 7 | ) 8 | 9 | // shouldHideClientIP determines if client IP addresses should be hidden from display. 10 | // 11 | // This function checks conditions in this priority order: 12 | // 1. Explicit --hide-client-ip flag (overrides everything) 13 | // 2. UPTERM_HIDE_CLIENT_IP environment variable (automatically bound by viper) 14 | // 3. Auto-detect CI environment (if neither flag nor env var set) 15 | // 16 | // This is particularly useful for CI/CD pipelines where session output is logged 17 | // and potentially publicly visible. By default, IPs are automatically hidden in 18 | // detected CI environments to prevent accidental exposure in build logs. 19 | // 20 | // Usage: 21 | // upterm host --hide-client-ip # Explicit flag 22 | // UPTERM_HIDE_CLIENT_IP=true upterm host # Environment variable (auto-bound) 23 | // upterm host # Auto-detects CI (GitHub Actions, etc.) 24 | func shouldHideClientIP() bool { 25 | // If flag is set (either via CLI flag or via UPTERM_HIDE_CLIENT_IP env var bound by viper) 26 | if flagHideClientIP { 27 | return true 28 | } 29 | 30 | // Auto-detect CI environments as fallback 31 | return isCI() 32 | } 33 | 34 | // isCI detects if the current process is running in a CI/CD environment 35 | // by checking for common CI environment variables. 36 | func isCI() bool { 37 | ciEnvVars := []string{ 38 | "CI", // Generic CI indicator (GitHub Actions, GitLab CI, etc.) 39 | "GITHUB_ACTIONS", // GitHub Actions 40 | "GITLAB_CI", // GitLab CI 41 | "CIRCLECI", // CircleCI 42 | "TRAVIS", // Travis CI 43 | "JENKINS_URL", // Jenkins 44 | "BUILDKITE", // Buildkite 45 | "TF_BUILD", // Azure Pipelines 46 | "TEAMCITY_VERSION", // TeamCity 47 | "BITBUCKET_BUILD_NUMBER", // Bitbucket Pipelines 48 | } 49 | 50 | for _, envVar := range ciEnvVars { 51 | if os.Getenv(envVar) != "" { 52 | return true 53 | } 54 | } 55 | 56 | return false 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | snapshot: 7 | description: 'Build snapshot (no publishing)' 8 | required: false 9 | default: true 10 | type: boolean 11 | docker_repo: 12 | description: 'Docker repository' 13 | required: false 14 | default: 'ghcr.io/owenthereal/upterm/uptermd' 15 | type: string 16 | 17 | jobs: 18 | goreleaser: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v6 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v6 27 | with: 28 | go-version-file: go.mod 29 | check-latest: true 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Set up Docker QEMU 35 | uses: docker/setup-qemu-action@v3 36 | with: 37 | platforms: 'amd64,arm64' 38 | 39 | - name: Login to ghcr.io 40 | if: ${{ !inputs.snapshot }} 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ghcr.io 44 | username: ${{ github.actor }} 45 | password: ${{ secrets.GH_TOKEN }} 46 | 47 | - name: Run GoReleaser (Snapshot) 48 | if: ${{ inputs.snapshot }} 49 | uses: goreleaser/goreleaser-action@v6 50 | with: 51 | distribution: goreleaser 52 | version: '~> v2' 53 | args: release --clean --snapshot --skip=publish 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 56 | DOCKER_REPO: ${{ inputs.docker_repo }} 57 | 58 | - name: Run GoReleaser (Release) 59 | if: ${{ !inputs.snapshot }} 60 | uses: goreleaser/goreleaser-action@v6 61 | with: 62 | distribution: goreleaser 63 | version: '~> v2' 64 | args: release --clean 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 67 | DOCKER_REPO: ${{ inputs.docker_repo }} 68 | -------------------------------------------------------------------------------- /charts/uptermd/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "upterm.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 7 | {{- end }} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "upterm.fullname" -}} 15 | {{- if .Values.fullnameOverride }} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 17 | {{- else }} 18 | {{- $name := default .Chart.Name .Values.nameOverride }} 19 | {{- if contains $name .Release.Name }} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 21 | {{- else }} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 23 | {{- end }} 24 | {{- end }} 25 | {{- end }} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "upterm.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 32 | {{- end }} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "upterm.labels" -}} 38 | helm.sh/chart: {{ include "upterm.chart" . }} 39 | {{ include "upterm.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end }} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "upterm.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "upterm.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end }} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "upterm.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create }} 59 | {{- default (include "upterm.fullname" .) .Values.serviceAccount.name }} 60 | {{- else }} 61 | {{- default "default" .Values.serviceAccount.name }} 62 | {{- end }} 63 | {{- end }} 64 | -------------------------------------------------------------------------------- /ws/client.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "encoding/base64" 5 | "net" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/gorilla/websocket" 10 | chshare "github.com/jpillora/chisel/share" 11 | "github.com/owenthereal/upterm/upterm" 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | // NewSSHClient creates a ssh client via ws. 16 | // The url must include username as session id and password as encoded node address. 17 | // isUptermClient indicates whether the client is host client or client client. 18 | func NewSSHClient(u *url.URL, config *ssh.ClientConfig, isUptermClient bool) (*ssh.Client, error) { 19 | conn, err := NewWSConn(u, isUptermClient) 20 | if err != nil { 21 | return nil, err 22 | } 23 | c, chans, reqs, err := ssh.NewClientConn(conn, u.Host, config) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return ssh.NewClient(c, chans, reqs), nil 29 | } 30 | 31 | // NewWSConn creates a ws net.Conn. 32 | // The url must include username as session id and password as encoded node address. 33 | // isUptermClient indicates whether the client is host client or client client. 34 | func NewWSConn(u *url.URL, isUptermClient bool) (net.Conn, error) { 35 | u, _ = url.Parse(u.String()) // clone 36 | user := u.User 37 | u.User = nil // ws spec doesn't support basic auth 38 | 39 | encodedNodeAddr, _ := user.Password() 40 | header := webSocketDialHeader(user.Username(), encodedNodeAddr, isUptermClient) 41 | wsc, _, err := websocket.DefaultDialer.Dial(u.String(), header) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return WrapWSConn(wsc), nil 47 | } 48 | 49 | func WrapWSConn(ws *websocket.Conn) net.Conn { 50 | return chshare.NewWebSocketConn(ws) 51 | } 52 | 53 | func webSocketDialHeader(sessionID, encodedNodeAddr string, isClient bool) http.Header { 54 | auth := base64.StdEncoding.EncodeToString([]byte(sessionID + ":" + encodedNodeAddr)) 55 | header := make(http.Header) 56 | header.Add("Authorization", "Basic "+auth) 57 | 58 | ver := upterm.HostSSHClientVersion 59 | if isClient { 60 | ver = upterm.ClientSSHClientVersion 61 | } 62 | header.Add(upterm.HeaderUptermClientVersion, ver) 63 | 64 | return header 65 | } 66 | -------------------------------------------------------------------------------- /fly.example.toml: -------------------------------------------------------------------------------- 1 | # Example Fly.io configuration for deploying your own uptermd server 2 | # Copy this file to fly.toml and customize the app name and other settings 3 | 4 | app = "my-uptermd-server" # Change this to your desired app name 5 | primary_region = "iad" # Change to your preferred region (iad, fra, nrt, etc.) 6 | kill_signal = "SIGINT" 7 | kill_timeout = "5s" 8 | 9 | [build] 10 | dockerfile = "Dockerfile.uptermd" 11 | build-target = "uptermd-fly" 12 | 13 | [experimental] 14 | entrypoint = ["uptermd-fly"] 15 | 16 | # Routing Configuration: 17 | # - If FLY_CONSUL_URL environment variable is set: uses Consul routing for multi-machine deployments 18 | # - If FLY_CONSUL_URL is not set: uses embedded routing for single-machine deployments (simpler setup) 19 | # For personal use, you don't need to set FLY_CONSUL_URL - embedded mode will be used automatically 20 | 21 | # Resource allocation - adjust based on your needs 22 | [vm] 23 | cpu_kind = "shared" 24 | cpus = 1 25 | memory_mb = 256 # Increase if you expect high usage 26 | 27 | # SSH service (port 22) 28 | [[services]] 29 | protocol = "tcp" 30 | internal_port = 2222 31 | auto_stop_machines = false 32 | auto_start_machines = true 33 | min_machines_running = 1 # Start with 1 for cost efficiency 34 | 35 | [[services.ports]] 36 | port = 22 37 | handlers = ["proxy_proto"] 38 | proxy_proto_options = { version = "v2" } 39 | 40 | [services.concurrency] 41 | type = "connections" 42 | hard_limit = 500 # Reduced for personal use 43 | soft_limit = 400 44 | 45 | [[services.tcp_checks]] 46 | interval = "15s" 47 | timeout = "2s" 48 | grace_period = "5s" 49 | restart_limit = 3 50 | 51 | # WebSocket service (ports 80/443) 52 | [[services]] 53 | protocol = "tcp" 54 | internal_port = 8080 55 | auto_stop_machines = false 56 | auto_start_machines = true 57 | min_machines_running = 1 # Start with 1 for cost efficiency 58 | 59 | [[services.ports]] 60 | port = 80 61 | handlers = ["http"] 62 | force_https = true 63 | 64 | [[services.ports]] 65 | port = 443 66 | handlers = ["tls", "http"] 67 | 68 | [services.concurrency] 69 | type = "connections" 70 | hard_limit = 500 # Reduced for personal use 71 | soft_limit = 400 72 | 73 | [[services.http_checks]] 74 | interval = "30s" 75 | timeout = "5s" 76 | grace_period = "10s" 77 | restart_limit = 3 78 | path = "/health" 79 | protocol = "http" -------------------------------------------------------------------------------- /etc/man/man1/upterm.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm - Instant Terminal Sharing 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Upterm is an open-source solution for sharing terminal sessions instantly over secure SSH tunnels to the public internet. 14 | 15 | .PP 16 | Configuration Priority (highest to lowest): 17 | 1. Command-line flags 18 | 2. Environment variables (UPTERM_ prefix) 19 | 3. Config file (see below) 20 | 4. Default values 21 | 22 | .PP 23 | Config File: 24 | ~/.config/upterm/config.yaml (Linux) 25 | ~/Library/Application Support/upterm/config.yaml (macOS) 26 | %LOCALAPPDATA%\\upterm\\config.yaml (Windows) 27 | 28 | .PP 29 | Run 'upterm config path' to see your config file location. 30 | Run 'upterm config edit' to create and edit the config file. 31 | 32 | .PP 33 | Environment Variables: 34 | All flags can be set via environment variables with the UPTERM_ prefix. 35 | Flag names are converted by replacing hyphens (-) with underscores (_). 36 | 37 | .PP 38 | Examples: 39 | --hide-client-ip → UPTERM_HIDE_CLIENT_IP=true 40 | --read-only → UPTERM_READ_ONLY=true 41 | --accept → UPTERM_ACCEPT=true 42 | 43 | 44 | .SH OPTIONS 45 | \fB--debug\fP[=false] 46 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 47 | 48 | .PP 49 | \fB-h\fP, \fB--help\fP[=false] 50 | help for upterm 51 | 52 | 53 | .SH EXAMPLE 54 | .EX 55 | # Host a terminal session running $SHELL, attaching client's IO to the host's: 56 | $ upterm host 57 | 58 | # Display the SSH connection string for sharing with client(s): 59 | $ upterm session current 60 | === SESSION_ID 61 | Command: /bin/bash 62 | Force Command: n/a 63 | Host: ssh://uptermd.upterm.dev:22 64 | SSH Session: ssh TOKEN@uptermd.upterm.dev 65 | 66 | # A client connects to the host session via SSH: 67 | $ ssh TOKEN@uptermd.upterm.dev 68 | 69 | # Set flags via environment variables: 70 | $ UPTERM_HIDE_CLIENT_IP=true upterm host 71 | .EE 72 | 73 | 74 | .SH SEE ALSO 75 | \fBupterm-config(1)\fP, \fBupterm-host(1)\fP, \fBupterm-proxy(1)\fP, \fBupterm-session(1)\fP, \fBupterm-upgrade(1)\fP, \fBupterm-version(1)\fP 76 | 77 | 78 | .SH HISTORY 79 | 29-Nov-2025 Auto generated by spf13/cobra 80 | -------------------------------------------------------------------------------- /docs/upterm.md: -------------------------------------------------------------------------------- 1 | ## upterm 2 | 3 | Instant Terminal Sharing 4 | 5 | ### Synopsis 6 | 7 | Upterm is an open-source solution for sharing terminal sessions instantly over secure SSH tunnels to the public internet. 8 | 9 | Configuration Priority (highest to lowest): 10 | 1. Command-line flags 11 | 2. Environment variables (UPTERM_ prefix) 12 | 3. Config file (see below) 13 | 4. Default values 14 | 15 | Config File: 16 | ~/.config/upterm/config.yaml (Linux) 17 | ~/Library/Application Support/upterm/config.yaml (macOS) 18 | %LOCALAPPDATA%\upterm\config.yaml (Windows) 19 | 20 | Run 'upterm config path' to see your config file location. 21 | Run 'upterm config edit' to create and edit the config file. 22 | 23 | Environment Variables: 24 | All flags can be set via environment variables with the UPTERM_ prefix. 25 | Flag names are converted by replacing hyphens (-) with underscores (_). 26 | 27 | Examples: 28 | --hide-client-ip → UPTERM_HIDE_CLIENT_IP=true 29 | --read-only → UPTERM_READ_ONLY=true 30 | --accept → UPTERM_ACCEPT=true 31 | 32 | ### Examples 33 | 34 | ``` 35 | # Host a terminal session running $SHELL, attaching client's IO to the host's: 36 | $ upterm host 37 | 38 | # Display the SSH connection string for sharing with client(s): 39 | $ upterm session current 40 | === SESSION_ID 41 | Command: /bin/bash 42 | Force Command: n/a 43 | Host: ssh://uptermd.upterm.dev:22 44 | SSH Session: ssh TOKEN@uptermd.upterm.dev 45 | 46 | # A client connects to the host session via SSH: 47 | $ ssh TOKEN@uptermd.upterm.dev 48 | 49 | # Set flags via environment variables: 50 | $ UPTERM_HIDE_CLIENT_IP=true upterm host 51 | ``` 52 | 53 | ### Options 54 | 55 | ``` 56 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 57 | -h, --help help for upterm 58 | ``` 59 | 60 | ### SEE ALSO 61 | 62 | * [upterm config](upterm_config.md) - Manage upterm configuration 63 | * [upterm host](upterm_host.md) - Host a terminal session 64 | * [upterm proxy](upterm_proxy.md) - Proxy a terminal session via WebSocket 65 | * [upterm session](upterm_session.md) - Display and manage terminal sessions 66 | * [upterm upgrade](upterm_upgrade.md) - Upgrade the CLI 67 | * [upterm version](upterm_version.md) - Show version 68 | 69 | ###### Auto generated by spf13/cobra on 29-Nov-2025 70 | -------------------------------------------------------------------------------- /io/writer.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | ) 7 | 8 | type buffer struct { 9 | mu sync.Mutex 10 | 11 | queue [][]byte 12 | size int 13 | } 14 | 15 | func (c *buffer) Append(p []byte) { 16 | c.mu.Lock() 17 | defer c.mu.Unlock() 18 | 19 | // remove first element if queue is full 20 | if len(c.queue) >= c.size { 21 | c.queue = c.queue[1:] 22 | } 23 | 24 | pp := make([]byte, len(p)) 25 | copy(pp, p) 26 | 27 | c.queue = append(c.queue, pp) 28 | } 29 | 30 | func (c *buffer) Size() int { 31 | c.mu.Lock() 32 | defer c.mu.Unlock() 33 | 34 | return len(c.queue) 35 | } 36 | 37 | func (c *buffer) Data() [][]byte { 38 | c.mu.Lock() 39 | defer c.mu.Unlock() 40 | 41 | result := make([][]byte, len(c.queue)) 42 | return append(result, c.queue...) 43 | } 44 | 45 | func NewMultiWriter(bufferSize int, writers ...io.Writer) *MultiWriter { 46 | return &MultiWriter{ 47 | writers: writers, 48 | buffer: &buffer{size: bufferSize}, 49 | } 50 | } 51 | 52 | // MultiWriter is a concurrent safe writer that allows appending/removing writers. 53 | // Newly appended writers get the last write to preserve last output. 54 | type MultiWriter struct { 55 | writeMu sync.Mutex 56 | writers []io.Writer 57 | 58 | buffer *buffer 59 | } 60 | 61 | func (t *MultiWriter) Append(writers ...io.Writer) error { 62 | // write last buffer to new writers 63 | if t.buffer.Size() > 0 { 64 | for _, w := range writers { 65 | for _, d := range t.buffer.Data() { 66 | _, err := w.Write(d) 67 | if err != nil { 68 | return err 69 | } 70 | } 71 | } 72 | } 73 | 74 | t.writeMu.Lock() 75 | defer t.writeMu.Unlock() 76 | t.writers = append(t.writers, writers...) 77 | 78 | return nil 79 | } 80 | 81 | func (t *MultiWriter) Remove(writers ...io.Writer) { 82 | t.writeMu.Lock() 83 | defer t.writeMu.Unlock() 84 | 85 | for i := len(t.writers) - 1; i > 0; i-- { 86 | for _, v := range writers { 87 | if t.writers[i] == v { 88 | t.writers = append(t.writers[:i], t.writers[i+1:]...) 89 | break 90 | } 91 | } 92 | } 93 | } 94 | 95 | func (t *MultiWriter) Write(p []byte) (n int, err error) { 96 | t.buffer.Append(p) 97 | 98 | t.writeMu.Lock() 99 | defer t.writeMu.Unlock() 100 | 101 | for _, w := range t.writers { 102 | n, err = w.Write(p) 103 | if err != nil { 104 | return 105 | } 106 | if n != len(p) { 107 | err = io.ErrShortWrite 108 | return 109 | } 110 | } 111 | 112 | return len(p), nil 113 | } 114 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '26 15 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v6 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v4 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v4 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v4 71 | -------------------------------------------------------------------------------- /io/reader_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "testing" 8 | "time" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | ) 12 | 13 | func Test_ContextReader(t *testing.T) { 14 | t.Run("happy path", func(t *testing.T) { 15 | t.Parallel() 16 | 17 | r := bytes.NewBufferString("hello1") 18 | w := bytes.NewBuffer(nil) 19 | 20 | _, _ = io.Copy(w, NewContextReader(context.Background(), r)) 21 | want := "hello1" 22 | got := w.String() 23 | if diff := cmp.Diff(want, got); diff != "" { 24 | t.Errorf("want=%s got=%s:\n%s", want, got, diff) 25 | } 26 | }) 27 | 28 | t.Run("pass in canceled context", func(t *testing.T) { 29 | t.Parallel() 30 | 31 | r := readFunc(func(p []byte) (int, error) { 32 | t.Error("should never get here") 33 | return 0, nil 34 | }) 35 | w := bytes.NewBuffer(nil) 36 | 37 | ctx, cancel := context.WithCancel(context.Background()) 38 | cancel() 39 | _, err := io.Copy(w, NewContextReader(ctx, r)) 40 | want := context.Canceled 41 | got := err 42 | if diff := cmp.Diff(want.Error(), got.Error()); diff != "" { 43 | t.Errorf("want=%s got=%s:\n%s", want, got, diff) 44 | } 45 | }) 46 | 47 | t.Run("cancel context during copy", func(t *testing.T) { 48 | t.Parallel() 49 | 50 | r := readFunc(func(p []byte) (int, error) { 51 | time.Sleep(5 * time.Second) // simulate slow read 52 | t.Error("should never get here") 53 | return 0, nil 54 | }) 55 | w := bytes.NewBuffer(nil) 56 | 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | go func() { 59 | time.Sleep(1 * time.Second) // cancel ctx before any read 60 | cancel() 61 | }() 62 | _, err := io.Copy(w, NewContextReader(ctx, r)) 63 | want := context.Canceled 64 | got := err 65 | if diff := cmp.Diff(want.Error(), got.Error()); diff != "" { 66 | t.Errorf("want=%s got=%s:\n%s", want, got, diff) 67 | } 68 | }) 69 | 70 | t.Run("cancel context after copy", func(t *testing.T) { 71 | t.Parallel() 72 | 73 | ch := make(chan string, 1) 74 | ch <- "hello2" // feed with one read and then it hangs 75 | r := readFunc(func(p []byte) (int, error) { 76 | s := <-ch 77 | return bytes.NewBufferString(s).Read(p) 78 | }) 79 | w := bytes.NewBuffer(nil) 80 | 81 | ctx, cancel := context.WithCancel(context.Background()) 82 | go func() { 83 | time.Sleep(3 * time.Second) // cancel ctx after first read 84 | cancel() 85 | }() 86 | _, err := io.Copy(w, NewContextReader(ctx, r)) 87 | want := context.Canceled 88 | got := err 89 | if diff := cmp.Diff(want.Error(), got.Error()); diff != "" { 90 | t.Errorf("want=%s got=%s:\n%s", want, got, diff) 91 | } 92 | }) 93 | } 94 | 95 | type readFunc func(p []byte) (n int, err error) 96 | 97 | func (rf readFunc) Read(p []byte) (n int, err error) { return rf(p) } 98 | -------------------------------------------------------------------------------- /terraform/heroku/main.tf: -------------------------------------------------------------------------------- 1 | variable "heroku_app_name" { 2 | description = "Heroku app name" 3 | } 4 | 5 | variable "heroku_region" { 6 | description = "Heroku region" 7 | default = "us" 8 | } 9 | 10 | variable "heroku_space" { 11 | description = "Name of the Heroku space" 12 | default = "" 13 | } 14 | 15 | variable "git_commit_sha" { 16 | description = "Git commit sha on GitHub" 17 | default = "master" 18 | } 19 | 20 | variable "heroku_team" { 21 | description = "Heroku team" 22 | default = "" 23 | } 24 | 25 | locals { 26 | app_id = var.heroku_space == "" ? heroku_app.uptermd_common_runtime.*.id[0] : heroku_app.uptermd_private_spaces.*.id[0] 27 | app_name = var.heroku_space == "" ? heroku_app.uptermd_common_runtime.*.name[0] : heroku_app.uptermd_private_spaces.*.name[0] 28 | } 29 | 30 | resource "heroku_app" "uptermd_common_runtime" { 31 | count = var.heroku_team == "" ? 1 : 0 32 | 33 | name = var.heroku_app_name 34 | region = var.heroku_region 35 | buildpacks = ["heroku/go"] 36 | space = var.heroku_space 37 | acm = false 38 | 39 | sensitive_config_vars = { 40 | PRIVATE_KEY = "${tls_private_key.private_key.private_key_pem}" 41 | } 42 | } 43 | 44 | resource "heroku_app" "uptermd_private_spaces" { 45 | count = var.heroku_team == "" ? 0 : 1 46 | 47 | name = var.heroku_app_name 48 | region = var.heroku_region 49 | buildpacks = ["heroku/go"] 50 | space = var.heroku_space 51 | acm = false 52 | 53 | sensitive_config_vars = { 54 | PRIVATE_KEY = "${tls_private_key.private_key.private_key_pem}" 55 | } 56 | 57 | organization { 58 | name = var.heroku_team 59 | } 60 | } 61 | 62 | resource "tls_private_key" "private_key" { 63 | algorithm = "RSA" 64 | rsa_bits = "4096" 65 | } 66 | 67 | resource "heroku_app_feature" "spaces-dns-discovery" { 68 | app_id = local.app_id 69 | name = "spaces-dns-discovery" 70 | enabled = var.heroku_space == "" ? false : true 71 | } 72 | 73 | resource "heroku_build" "uptermd" { 74 | app_id = local.app_id 75 | 76 | source { 77 | url = "https://github.com/owenthereal/upterm/archive/${var.git_commit_sha}.tar.gz" 78 | version = var.git_commit_sha 79 | } 80 | } 81 | 82 | resource "heroku_formation" "uptermd" { 83 | app_id = local.app_id 84 | type = "web" 85 | quantity = var.heroku_space == "" ? 1 : 2 86 | size = var.heroku_space == "" ? "eco" : "private-s" 87 | depends_on = [heroku_build.uptermd] 88 | } 89 | 90 | output "step_1_share_session" { 91 | value = "upterm host --server wss://${local.app_name}.herokuapp.com -- YOUR_COMMAND" 92 | } 93 | 94 | output "step_2_join_session" { 95 | value = "ssh -o ProxyCommand='upterm proxy wss://TOKEN@${local.app_name}.herokuapp.com' TOKEN@${local.app_name}.herokuapp.com:443" 96 | } 97 | -------------------------------------------------------------------------------- /server/sshd_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/owenthereal/upterm/internal/logging" 11 | "github.com/owenthereal/upterm/routing" 12 | "github.com/owenthereal/upterm/upterm" 13 | "github.com/owenthereal/upterm/utils" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | const ( 18 | TestPublicKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0EWrjdcHcuMfI8bGAyHPcGsAc/vd/gl5673pRkRBGY` 19 | TestPrivateKeyContent = `-----BEGIN OPENSSH PRIVATE KEY----- 20 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 21 | QyNTUxOQAAACDdBFq43XB3LjHyPGxgMhz3BrAHP73f4Jeeu96UZEQRmAAAAIiRPFazkTxW 22 | swAAAAtzc2gtZWQyNTUxOQAAACDdBFq43XB3LjHyPGxgMhz3BrAHP73f4Jeeu96UZEQRmA 23 | AAAEDmpjZHP/SIyBTp6YBFPzUi18iDo2QHolxGRDpx+m7let0EWrjdcHcuMfI8bGAyHPcG 24 | sAc/vd/gl5673pRkRBGYAAAAAAECAwQF 25 | -----END OPENSSH PRIVATE KEY-----` 26 | ) 27 | 28 | func Test_sshd_DisallowSession(t *testing.T) { 29 | logger := logging.Must(logging.Console(), logging.Debug()).Logger 30 | 31 | ln, err := net.Listen("tcp", "127.0.0.1:0") 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | defer func() { 36 | _ = ln.Close() 37 | }() 38 | 39 | addr := ln.Addr().String() 40 | 41 | signer, err := ssh.ParsePrivateKey([]byte(TestPrivateKeyContent)) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | // Set up cert signer for sshd public key validation 47 | cs := UserCertSigner{ 48 | SessionID: "1234", 49 | User: "owen", 50 | AuthRequest: &AuthRequest{ 51 | ClientVersion: upterm.HostSSHClientVersion, 52 | RemoteAddr: addr, 53 | AuthorizedKey: []byte(TestPublicKeyContent), 54 | }, 55 | } 56 | certSigner, err := cs.SignCert(signer) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | sshd := &sshd{ 62 | SessionManager: func() *SessionManager { 63 | sm, _ := NewSessionManager(routing.ModeEmbedded, 64 | WithSessionManagerLogger(logger)) 65 | return sm 66 | }(), 67 | HostSigners: []ssh.Signer{signer}, 68 | NodeAddr: addr, 69 | Logger: logger, 70 | } 71 | 72 | go func() { 73 | _ = sshd.Serve(ln) 74 | }() 75 | 76 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 77 | defer cancel() 78 | 79 | if err := utils.WaitForServer(ctx, addr); err != nil { 80 | t.Fatal(err) 81 | } 82 | 83 | config := &ssh.ClientConfig{ 84 | Auth: []ssh.AuthMethod{ssh.PublicKeys(certSigner)}, 85 | User: "owen", 86 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 87 | } 88 | client, err := ssh.Dial("tcp", addr, config) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | 93 | _, err = client.NewSession() 94 | if err == nil || !strings.Contains(err.Error(), "unsupported channel type") { 95 | t.Fatalf("expect unsupported channel type error but got %v", err) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | permissions: 8 | contents: write 9 | packages: write 10 | jobs: 11 | build: 12 | name: Compile 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [macos-latest, ubuntu-latest, windows-latest] 18 | steps: 19 | - uses: actions/checkout@v6 20 | - name: Set up Go 21 | uses: actions/setup-go@v6 22 | with: 23 | go-version-file: go.mod 24 | check-latest: true 25 | - name: Compile 26 | run: make install 27 | test-macos: 28 | name: Test (macOS) 29 | runs-on: macos-latest 30 | steps: 31 | - uses: actions/checkout@v6 32 | - name: Set up Go 33 | uses: actions/setup-go@v6 34 | with: 35 | go-version-file: go.mod 36 | check-latest: true 37 | - name: Test 38 | run: make test 39 | env: 40 | BASH_SILENCE_DEPRECATION_WARNING: 1 41 | MUTE_FLAKY_TESTS: 1 42 | 43 | test-ubuntu: 44 | name: Test (Ubuntu + Consul) 45 | runs-on: ubuntu-latest 46 | services: 47 | consul: 48 | image: consul:1.15 49 | ports: 50 | - 8500:8500 51 | options: >- 52 | --health-cmd "consul members" 53 | --health-interval 10s 54 | --health-timeout 5s 55 | --health-retries 5 56 | steps: 57 | - uses: actions/checkout@v6 58 | - name: Set up Go 59 | uses: actions/setup-go@v6 60 | with: 61 | go-version-file: go.mod 62 | check-latest: true 63 | - name: Test 64 | run: make test 65 | env: 66 | BASH_SILENCE_DEPRECATION_WARNING: 1 67 | MUTE_FLAKY_TESTS: 1 68 | 69 | test-windows: 70 | name: Test (Windows) 71 | runs-on: windows-latest 72 | steps: 73 | - uses: actions/checkout@v6 74 | - name: Set up Go 75 | uses: actions/setup-go@v6 76 | with: 77 | go-version-file: go.mod 78 | check-latest: true 79 | - name: Test 80 | run: make test 81 | env: 82 | MUTE_FLAKY_TESTS: 1 83 | vet: 84 | name: Vet 85 | runs-on: ubuntu-latest 86 | steps: 87 | - uses: actions/checkout@v6 88 | - name: Set up Go 89 | uses: actions/setup-go@v6 90 | with: 91 | go-version-file: go.mod 92 | check-latest: true 93 | - name: Vet 94 | run: make vet 95 | build-and-release: 96 | name: Build and Release (Snapshot) 97 | uses: ./.github/workflows/build-and-release.yaml 98 | with: 99 | snapshot: true 100 | docker_repo: ghcr.io/owenthereal/upterm/uptermd 101 | secrets: inherit 102 | -------------------------------------------------------------------------------- /server/wsproxy_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "net" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/owenthereal/upterm/internal/logging" 13 | "github.com/owenthereal/upterm/ws" 14 | "google.golang.org/grpc/test/bufconn" 15 | ) 16 | 17 | type testSshdDialListener struct { 18 | *bufconn.Listener 19 | } 20 | 21 | func (l *testSshdDialListener) Dial() (net.Conn, error) { 22 | return l.Listener.Dial() 23 | } 24 | 25 | func (l *testSshdDialListener) Listen() (net.Listener, error) { 26 | return l.Listener, nil 27 | } 28 | 29 | type testSessionDialListener struct { 30 | *bufconn.Listener 31 | } 32 | 33 | func (l *testSessionDialListener) Dial(id string) (net.Conn, error) { 34 | return l.Listener.Dial() 35 | } 36 | 37 | func (l *testSessionDialListener) Listen(id string) (net.Listener, error) { 38 | return l.Listener, nil 39 | } 40 | 41 | func Test_WebSocketProxy_Host(t *testing.T) { 42 | testLogger := logging.Must(logging.Console(), logging.Debug()).Logger 43 | cd := sidewayConnDialer{ 44 | SSHDDialListener: &testSshdDialListener{bufconn.Listen(1024)}, 45 | SessionDialListener: &testSessionDialListener{bufconn.Listen(1024)}, 46 | Logger: testLogger, 47 | } 48 | logger := testLogger 49 | wsh := &wsHandler{ 50 | ConnDialer: cd, 51 | SessionManager: newEmbeddedSessionManager(logger), 52 | Logger: logger, 53 | } 54 | ts := httptest.NewServer(wsh) 55 | defer ts.Close() 56 | 57 | u, err := url.Parse(ts.URL) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | u.Scheme = "ws" 62 | u.User = url.UserPassword("owen", "") 63 | 64 | wsc, err := ws.NewWSConn(u, false) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | rr, rw := io.Pipe() 70 | rs := bufio.NewScanner(rr) 71 | go func(wsc net.Conn, w io.Writer) { 72 | _, _ = io.Copy(w, wsc) 73 | }(wsc, rw) 74 | 75 | ln, err := cd.SSHDDialListener.Listen() 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | conn, err := ln.Accept() 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | wr, ww := io.Pipe() 85 | ws := bufio.NewScanner(wr) 86 | go func() { 87 | _, _ = io.Copy(ww, conn) 88 | }() 89 | 90 | // test read 91 | _, _ = conn.Write([]byte("read\n")) // need CR because func scan scans by line 92 | if diff := cmp.Diff("read", scan(rs)); diff != "" { 93 | t.Fatal(diff) 94 | } 95 | 96 | // test write 97 | if _, err := wsc.Write([]byte("write\n")); err != nil { // need CR because func scan scans by line 98 | t.Fatal(err) 99 | } 100 | if diff := cmp.Diff("write", scan(ws)); diff != "" { 101 | t.Fatal(diff) 102 | } 103 | } 104 | 105 | func scan(s *bufio.Scanner) string { 106 | for s.Scan() { 107 | return s.Text() 108 | } 109 | 110 | return s.Err().Error() 111 | } 112 | -------------------------------------------------------------------------------- /host/internal/pty_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package internal 4 | 5 | import ( 6 | "os" 7 | "os/exec" 8 | "sync" 9 | "syscall" 10 | 11 | ptylib "github.com/creack/pty" 12 | ) 13 | 14 | func startPty(c *exec.Cmd, stdin *os.File) (PTY, error) { 15 | // Create PTY with kernel defaults first 16 | f, err := ptylib.Start(c) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | // Set the initial size from stdin if available 22 | if stdin != nil { 23 | h, w, err := getPtysize(stdin) 24 | if err == nil && w > 0 && h > 0 { 25 | // Set the PTY size before returning 26 | // Ignore error - process is already running, will use kernel defaults if this fails 27 | _ = ptylib.Setsize(f, &ptylib.Winsize{ 28 | Rows: uint16(h), 29 | Cols: uint16(w), 30 | }) 31 | } 32 | } 33 | 34 | return wrapPty(f, c), nil 35 | } 36 | 37 | // Linux kernel return EIO when attempting to read from a master pseudo 38 | // terminal which no longer has an open slave. So ignore error here. 39 | // See https://github.com/creack/pty/issues/21 40 | func ptyError(err error) error { 41 | if pathErr, ok := err.(*os.PathError); !ok || pathErr.Err != syscall.EIO { 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func getPtysize(f *os.File) (h, w int, err error) { 49 | return ptylib.Getsize(f) 50 | } 51 | 52 | func wrapPty(f *os.File, cmd *exec.Cmd) *pty { 53 | return &pty{File: f, cmd: cmd} 54 | } 55 | 56 | // Pty is a wrapper of the pty *os.File that provides a read/write mutex. 57 | // This is to prevent data race that might happen for reszing, reading and closing. 58 | // See ftests failure: 59 | // * https://travis-ci.org/owenthereal/upterm/jobs/632489866 60 | // * https://travis-ci.org/owenthereal/upterm/jobs/632458125 61 | type pty struct { 62 | *os.File 63 | cmd *exec.Cmd // Process started with this PTY 64 | sync.RWMutex 65 | } 66 | 67 | func (pty *pty) Setsize(h, w int) error { 68 | pty.RLock() 69 | defer pty.RUnlock() 70 | 71 | size := &ptylib.Winsize{ 72 | Rows: uint16(h), 73 | Cols: uint16(w), 74 | } 75 | return ptylib.Setsize(pty.File, size) 76 | } 77 | 78 | func (pty *pty) Read(p []byte) (n int, err error) { 79 | pty.RLock() 80 | defer pty.RUnlock() 81 | 82 | return pty.File.Read(p) 83 | } 84 | 85 | func (pty *pty) Close() error { 86 | pty.Lock() 87 | defer pty.Unlock() 88 | 89 | return pty.File.Close() 90 | } 91 | 92 | // Wait waits for the process to exit 93 | func (pty *pty) Wait() error { 94 | pty.RLock() 95 | cmd := pty.cmd 96 | pty.RUnlock() 97 | 98 | if cmd == nil { 99 | return nil // No process to wait for 100 | } 101 | return cmd.Wait() 102 | } 103 | 104 | // Kill terminates the process 105 | func (pty *pty) Kill() error { 106 | pty.RLock() 107 | cmd := pty.cmd 108 | pty.RUnlock() 109 | 110 | if cmd == nil || cmd.Process == nil { 111 | return nil // No process to kill 112 | } 113 | return cmd.Process.Kill() 114 | } 115 | -------------------------------------------------------------------------------- /memlistener/memlistener.go: -------------------------------------------------------------------------------- 1 | package memlistener 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "sync" 8 | 9 | "google.golang.org/grpc/test/bufconn" 10 | ) 11 | 12 | var ( 13 | errMissingAddress = errors.New("missing address") 14 | ) 15 | 16 | const ( 17 | defaultBufferSize = 256 * 1024 18 | ) 19 | 20 | type addr struct{} 21 | 22 | func (addr) Network() string { return "mem" } 23 | func (addr) String() string { return "mem" } 24 | 25 | type errListenerAlreadyExist struct { 26 | addr string 27 | } 28 | 29 | func (e errListenerAlreadyExist) Error() string { 30 | return fmt.Sprintf("listener with address %s already exist", e.addr) 31 | } 32 | 33 | type errListenerNotFound struct { 34 | addr string 35 | } 36 | 37 | func (e errListenerNotFound) Error() string { 38 | return fmt.Sprintf("listener with address %s not found", e.addr) 39 | } 40 | 41 | func New() *MemoryListener { 42 | return &MemoryListener{} 43 | } 44 | 45 | type MemoryListener struct { 46 | listeners sync.Map 47 | } 48 | 49 | func (l *MemoryListener) Listen(network, address string) (net.Listener, error) { 50 | return l.ListenMem(network, address, defaultBufferSize) 51 | } 52 | 53 | func (l *MemoryListener) ListenMem(network, address string, sz int) (net.Listener, error) { 54 | switch network { 55 | case "mem", "memory": 56 | default: 57 | return nil, &net.OpError{Op: "listen", Net: network, Source: nil, Addr: addr{}, Err: net.UnknownNetworkError(network)} 58 | } 59 | 60 | if address == "" { 61 | return nil, &net.OpError{Op: "listen", Net: network, Source: nil, Addr: addr{}, Err: errMissingAddress} 62 | } 63 | 64 | ln := &memlistener{ 65 | Listener: bufconn.Listen(sz), 66 | addr: address, 67 | closeFunc: l.removeListener, 68 | } 69 | actual, loaded := l.listeners.LoadOrStore(address, ln) 70 | if loaded { 71 | return nil, &net.OpError{Op: "listen", Net: network, Source: nil, Addr: addr{}, Err: errListenerAlreadyExist{address}} 72 | } 73 | 74 | return actual.(net.Listener), nil 75 | } 76 | 77 | func (l *MemoryListener) Dial(network, address string) (net.Conn, error) { 78 | switch network { 79 | case "mem", "memory": 80 | default: 81 | return nil, &net.OpError{Op: "dial", Net: network, Source: addr{}, Addr: addr{}, Err: net.UnknownNetworkError(network)} 82 | } 83 | 84 | if address == "" { 85 | return nil, &net.OpError{Op: "dial", Net: network, Source: addr{}, Addr: addr{}, Err: errMissingAddress} 86 | } 87 | 88 | val, exist := l.listeners.Load(address) 89 | if !exist { 90 | return nil, &net.OpError{Op: "dial", Net: network, Source: addr{}, Addr: addr{}, Err: errListenerNotFound{address}} 91 | } 92 | 93 | ln := val.(*memlistener) 94 | 95 | return ln.Dial() 96 | } 97 | 98 | func (l *MemoryListener) removeListener(address string) { 99 | l.listeners.Delete(address) 100 | } 101 | 102 | type memlistener struct { 103 | *bufconn.Listener 104 | addr string 105 | closeFunc func(addr string) 106 | } 107 | 108 | func (m *memlistener) Close() error { 109 | defer m.closeFunc(m.addr) 110 | return m.Listener.Close() 111 | } 112 | -------------------------------------------------------------------------------- /routing/encoding_test.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/suite" 7 | ) 8 | 9 | // EncodeDecoderTestSuite tests the EncodeDecoder implementations 10 | type EncodeDecoderTestSuite struct { 11 | suite.Suite 12 | } 13 | 14 | func (suite *EncodeDecoderTestSuite) TestEmbeddedEncodeDecoder() { 15 | sessionID := "test-session-123" 16 | nodeAddr := "node1.example.com:22" 17 | 18 | encoder := NewEncodeDecoder(ModeEmbedded) 19 | 20 | // Test encoding 21 | encoded := encoder.Encode(sessionID, nodeAddr) 22 | suite.Contains(encoded, ":") 23 | suite.Contains(encoded, sessionID) 24 | 25 | // Test decoding 26 | decodedSessionID, decodedNodeAddr, err := encoder.Decode(encoded) 27 | suite.NoError(err) 28 | suite.Equal(sessionID, decodedSessionID) 29 | suite.Equal(nodeAddr, decodedNodeAddr) 30 | suite.Equal(ModeEmbedded, encoder.Mode()) 31 | } 32 | 33 | func (suite *EncodeDecoderTestSuite) TestConsulEncodeDecoder() { 34 | sessionID := "test-session-456" 35 | 36 | encoder := NewEncodeDecoder(ModeConsul) 37 | 38 | // Test encoding (should just return session ID) 39 | encoded := encoder.Encode(sessionID, "any-node") 40 | suite.Equal(sessionID, encoded) 41 | 42 | // Test decoding 43 | decodedSessionID, decodedNodeAddr, err := encoder.Decode(encoded) 44 | suite.NoError(err) 45 | suite.Equal(sessionID, decodedSessionID) 46 | suite.Empty(decodedNodeAddr) 47 | suite.Equal(ModeConsul, encoder.Mode()) 48 | } 49 | 50 | func (suite *EncodeDecoderTestSuite) TestEmbeddedDecodeInvalidFormats() { 51 | decoder := NewEncodeDecoder(ModeEmbedded) 52 | 53 | testCases := []struct { 54 | input string 55 | description string 56 | }{ 57 | {"no-colon-here", "no colon separator"}, 58 | {"", "empty string"}, 59 | {"session:invalid-base64===!@#$", "invalid base64 characters"}, 60 | } 61 | 62 | for _, tc := range testCases { 63 | suite.Run(tc.description, func() { 64 | _, _, err := decoder.Decode(tc.input) 65 | suite.Error(err) 66 | }) 67 | } 68 | } 69 | 70 | func (suite *EncodeDecoderTestSuite) TestConsulDecodeInvalidFormats() { 71 | decoder := NewEncodeDecoder(ModeConsul) 72 | 73 | // Test empty session ID 74 | _, _, err := decoder.Decode("") 75 | suite.Error(err) 76 | } 77 | 78 | func (suite *EncodeDecoderTestSuite) TestConsulDecodeBackwardCompatibility() { 79 | sessionID := "test-session-123" 80 | nodeAddr := "127.0.0.1:2222" 81 | 82 | // Create an embedded format SSH user (what old clients send) 83 | embeddedEncoder := NewEncodeDecoder(ModeEmbedded) 84 | embeddedSSHUser := embeddedEncoder.Encode(sessionID, nodeAddr) 85 | 86 | // Test that Consul decoder can handle embedded format 87 | consulDecoder := NewEncodeDecoder(ModeConsul) 88 | decodedSessionID, decodedNodeAddr, err := consulDecoder.Decode(embeddedSSHUser) 89 | 90 | suite.NoError(err) 91 | suite.Equal(sessionID, decodedSessionID, "should extract session ID from embedded format") 92 | suite.Empty(decodedNodeAddr, "consul decoder should return empty node address") 93 | 94 | // Test that it still works with pure consul format 95 | decodedSessionID2, decodedNodeAddr2, err2 := consulDecoder.Decode(sessionID) 96 | suite.NoError(err2) 97 | suite.Equal(sessionID, decodedSessionID2, "should handle pure consul format") 98 | suite.Empty(decodedNodeAddr2, "consul decoder should return empty node address") 99 | } 100 | 101 | // Test suite runners 102 | func TestEncodeDecoderSuite(t *testing.T) { 103 | suite.Run(t, new(EncodeDecoderTestSuite)) 104 | } 105 | -------------------------------------------------------------------------------- /routing/encoding.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | ErrInvalidSSHUser = fmt.Errorf("invalid SSH user") 11 | ) 12 | 13 | // Encoder defines the interface for encoding session information into SSH usernames 14 | type Encoder interface { 15 | // Encode encodes a session ID and node address into an SSH username 16 | Encode(sessionID, nodeAddr string) string 17 | } 18 | 19 | // Decoder defines the interface for decoding SSH usernames into session information 20 | type Decoder interface { 21 | // Decode decodes an SSH username into session ID and node address 22 | Decode(sshUser string) (sessionID, nodeAddr string, err error) 23 | } 24 | 25 | // ModeProvider defines the interface for getting the routing mode 26 | type ModeProvider interface { 27 | // Mode returns the routing mode for this encoder/decoder 28 | Mode() Mode 29 | } 30 | 31 | // EncodeDecoder defines the composite interface for encoding and decoding SSH usernames 32 | type EncodeDecoder interface { 33 | Encoder 34 | Decoder 35 | ModeProvider 36 | } 37 | 38 | // NewEncoder creates an Encoder for the specified routing mode 39 | func NewEncoder(mode Mode) Encoder { 40 | return NewEncodeDecoder(mode) 41 | } 42 | 43 | // NewDecoder creates a Decoder for the specified routing mode 44 | func NewDecoder(mode Mode) Decoder { 45 | return NewEncodeDecoder(mode) 46 | } 47 | 48 | // NewEncodeDecoder creates an EncodeDecoder for the specified routing mode 49 | func NewEncodeDecoder(mode Mode) EncodeDecoder { 50 | switch mode { 51 | case ModeEmbedded: 52 | return &EmbeddedEncodeDecoder{} 53 | case ModeConsul: 54 | return &ConsulEncodeDecoder{} 55 | default: 56 | return &EmbeddedEncodeDecoder{} // Default to embedded 57 | } 58 | } 59 | 60 | // EmbeddedEncodeDecoder implements EncodeDecoder for embedded routing mode 61 | type EmbeddedEncodeDecoder struct{} 62 | 63 | func (e *EmbeddedEncodeDecoder) Encode(sessionID, nodeAddr string) string { 64 | return sessionID + ":" + base64.URLEncoding.EncodeToString([]byte(nodeAddr)) 65 | } 66 | 67 | func (e *EmbeddedEncodeDecoder) Decode(sshUser string) (sessionID, nodeAddr string, err error) { 68 | split := strings.SplitN(sshUser, ":", 2) 69 | if len(split) != 2 { 70 | return "", "", ErrInvalidSSHUser 71 | } 72 | 73 | nodeAddrBytes, err := base64.URLEncoding.DecodeString(split[1]) 74 | if err != nil { 75 | return "", "", fmt.Errorf("failed to decode node address: %w", err) 76 | } 77 | 78 | return split[0], string(nodeAddrBytes), nil 79 | } 80 | 81 | func (e *EmbeddedEncodeDecoder) Mode() Mode { 82 | return ModeEmbedded 83 | } 84 | 85 | // ConsulEncodeDecoder implements EncodeDecoder for Consul routing mode 86 | type ConsulEncodeDecoder struct{} 87 | 88 | func (c *ConsulEncodeDecoder) Encode(sessionID, nodeAddr string) string { 89 | return sessionID 90 | } 91 | 92 | func (c *ConsulEncodeDecoder) Decode(sshUser string) (sessionID, nodeAddr string, err error) { 93 | if sshUser == "" { 94 | return "", "", ErrInvalidSSHUser 95 | } 96 | 97 | // In Consul mode, the SSH user is just the session ID 98 | // Handle mixed-mode scenarios: if SSH user contains ":" (embedded format), 99 | // extract only the session ID part (before the colon) for compatibility 100 | if colonIndex := strings.Index(sshUser, ":"); colonIndex != -1 { 101 | sessionID = sshUser[:colonIndex] 102 | } else { 103 | sessionID = sshUser 104 | } 105 | 106 | return sessionID, "", nil 107 | } 108 | 109 | func (c *ConsulEncodeDecoder) Mode() Mode { 110 | return ModeConsul 111 | } 112 | -------------------------------------------------------------------------------- /charts/uptermd/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "upterm.fullname" . }} 5 | labels: 6 | {{- include "upterm.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "upterm.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "upterm.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "upterm.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | containers: 31 | - name: {{ .Chart.Name }} 32 | securityContext: 33 | {{- toYaml .Values.securityContext | nindent 12 }} 34 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 35 | imagePullPolicy: {{ .Values.image.pullPolicy }} 36 | args: 37 | - --ssh-addr 38 | - $(POD_IP):22 39 | {{- if .Values.websocket.enabled }} 40 | - --ws-addr 41 | - $(POD_IP):80 42 | {{- end }} 43 | - --node-addr 44 | - $(POD_IP):22 45 | - --hostname 46 | - {{ .Values.hostname }} 47 | {{- range $key, $val := .Values.host_keys }} 48 | {{ if hasSuffix ".pub" $key }} 49 | {{ else }} 50 | - --private-key 51 | - /host-keys/{{ $key }} 52 | {{- end }} 53 | {{- end }} 54 | - --network 55 | - mem 56 | - --metric-addr 57 | - $(POD_IP):9090 58 | {{- if .Values.debug }} 59 | - --debug 60 | {{- end }} 61 | env: 62 | - name: POD_IP 63 | valueFrom: 64 | fieldRef: 65 | fieldPath: status.podIP 66 | ports: 67 | - containerPort: 22 68 | name: sshd 69 | {{- if .Values.websocket.enabled }} 70 | - containerPort: 80 71 | name: ws 72 | {{- end }} 73 | - containerPort: 9090 74 | name: exporter 75 | readinessProbe: 76 | tcpSocket: 77 | port: 22 78 | periodSeconds: 10 79 | livenessProbe: 80 | tcpSocket: 81 | port: 22 82 | periodSeconds: 20 83 | resources: 84 | {{- toYaml .Values.resources | nindent 12 }} 85 | volumeMounts: 86 | - mountPath: /host-keys 87 | name: host-keys 88 | volumes: 89 | - name: host-keys 90 | secret: 91 | secretName: {{ include "upterm.fullname" . }} 92 | defaultMode: 0600 93 | {{- with .Values.nodeSelector }} 94 | nodeSelector: 95 | {{- toYaml . | nindent 8 }} 96 | {{- end }} 97 | {{- with .Values.affinity }} 98 | affinity: 99 | {{- toYaml . | nindent 8 }} 100 | {{- end }} 101 | {{- with .Values.tolerations }} 102 | tolerations: 103 | {{- toYaml . | nindent 8 }} 104 | {{- end }} 105 | -------------------------------------------------------------------------------- /docs/upterm_host.md: -------------------------------------------------------------------------------- 1 | ## upterm host 2 | 3 | Host a terminal session 4 | 5 | ### Synopsis 6 | 7 | Host a terminal session via a reverse SSH tunnel to the Upterm server. 8 | 9 | The session links the host and client IO to a command's IO. Authentication with the 10 | Upterm server uses private keys in this order: 11 | 1. Private key files: ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, ~/.ssh/id_rsa 12 | 2. SSH Agent keys 13 | 3. Auto-generated ephemeral key (if no keys found) 14 | 15 | To authorize client connections, use --authorized-keys to specify an authorized_keys file 16 | containing client public keys. 17 | 18 | ``` 19 | upterm host [flags] 20 | ``` 21 | 22 | ### Examples 23 | 24 | ``` 25 | # Host a terminal session running $SHELL, attaching client's IO to the host's: 26 | upterm host 27 | 28 | # Accept client connections automatically without prompts: 29 | upterm host --accept 30 | 31 | # Host a terminal session allowing only specified public key(s) to connect: 32 | upterm host --authorized-keys PATH_TO_AUTHORIZED_KEY_FILE 33 | 34 | # Host a session executing a custom command: 35 | upterm host -- docker run --rm -ti ubuntu bash 36 | 37 | # Host a 'tmux new -t pair-programming' session, forcing clients to join with 'tmux attach -t pair-programming': 38 | upterm host --force-command 'tmux attach -t pair-programming' -- tmux new -t pair-programming 39 | 40 | # Use a different Uptermd server, hosting a session via WebSocket: 41 | upterm host --server wss://YOUR_UPTERMD_SERVER -- YOUR_COMMAND 42 | ``` 43 | 44 | ### Options 45 | 46 | ``` 47 | --accept Automatically accept client connections without prompts. 48 | --authorized-keys string Specify a authorize_keys file listing authorized public keys for connection. 49 | --codeberg-user strings Authorize specified Codeberg users by allowing their public keys to connect. 50 | -f, --force-command string Enforce a specified command for clients to join, and link the command's input/output to the client's terminal. 51 | --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. 52 | --gitlab-user strings Authorize specified GitLab users by allowing their public keys to connect. 53 | -h, --help help for host 54 | --hide-client-ip Hide client IP addresses from output (auto-enabled in CI environments). 55 | --known-hosts string Specify a file containing known keys for remote hosts (required). (default "/Users/owen/.ssh/known_hosts") 56 | -i, --private-key strings Specify private key files for public key authentication with the upterm server (required). (default [/Users/owen/.ssh/id_ed25519]) 57 | -r, --read-only Host a read-only session, preventing client interaction. 58 | --server string Specify the upterm server address (required). Supported protocols: ssh, ws, wss. (default "ssh://uptermd.upterm.dev:22") 59 | --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. 60 | --srht-user strings Authorize specified SourceHut users by allowing their public keys to connect. 61 | ``` 62 | 63 | ### Options inherited from parent commands 64 | 65 | ``` 66 | --debug enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 67 | ``` 68 | 69 | ### SEE ALSO 70 | 71 | * [upterm](upterm.md) - Instant Terminal Sharing 72 | 73 | ###### Auto generated by spf13/cobra on 29-Nov-2025 74 | -------------------------------------------------------------------------------- /host/signer.go: -------------------------------------------------------------------------------- 1 | package host 2 | 3 | import ( 4 | "bytes" 5 | "crypto/x509" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "os" 10 | "strings" 11 | "syscall" 12 | 13 | "github.com/owenthereal/upterm/utils" 14 | "golang.org/x/crypto/ssh" 15 | "golang.org/x/crypto/ssh/agent" 16 | "golang.org/x/term" 17 | ) 18 | 19 | const ( 20 | errCannotDecodeEncryptedPrivateKeys = "cannot decode encrypted private keys" 21 | ) 22 | 23 | type errDescryptingPrivateKey struct { 24 | file string 25 | } 26 | 27 | func (e *errDescryptingPrivateKey) Error() string { 28 | return fmt.Sprintf("error decrypting private key %s", e.file) 29 | } 30 | 31 | // Signers return signers based on the following conditions: 32 | // If SSH agent is running and has keys, it returns signers from SSH agent, otherwise return signers from private keys; 33 | // If neither works, it generates a signer on the fly. 34 | func Signers(privateKeys []string) ([]ssh.Signer, func(), error) { 35 | var ( 36 | signers []ssh.Signer 37 | cleanup func() 38 | err error 39 | ) 40 | 41 | signers, cleanup, err = signersFromSSHAgent(os.Getenv("SSH_AUTH_SOCK")) 42 | if len(signers) == 0 || err != nil { 43 | signers, err = SignersFromFiles(privateKeys) 44 | } 45 | 46 | if err != nil { 47 | signers, err = utils.CreateSigners(nil) 48 | } 49 | 50 | return signers, cleanup, err 51 | } 52 | 53 | func SignersFromFiles(privateKeys []string) ([]ssh.Signer, error) { 54 | var signers []ssh.Signer 55 | for _, file := range privateKeys { 56 | s, err := signerFromFile(file, promptForPassphrase) 57 | if err == nil { 58 | signers = append(signers, s) 59 | } 60 | } 61 | 62 | return signers, nil 63 | } 64 | 65 | func signersFromSSHAgent(socket string) ([]ssh.Signer, func(), error) { 66 | cleanup := func() {} 67 | if socket == "" { 68 | return nil, cleanup, fmt.Errorf("SSH Agent is not running") 69 | } 70 | 71 | conn, err := net.Dial("unix", socket) 72 | if err != nil { 73 | return nil, cleanup, err 74 | } 75 | cleanup = func() { _ = conn.Close() } 76 | 77 | client := agent.NewClient(conn) 78 | signers, err := client.Signers() 79 | 80 | return signers, cleanup, err 81 | } 82 | 83 | func signerFromFile(file string, promptForPassphrase func(file string) ([]byte, error)) (ssh.Signer, error) { 84 | key, err := readPrivateKeyFromFile(file, promptForPassphrase) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return ssh.NewSignerFromKey(key) 90 | } 91 | 92 | func readPrivateKeyFromFile(file string, promptForPassphrase func(file string) ([]byte, error)) (interface{}, error) { 93 | pb, err := os.ReadFile(file) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | key, err := ssh.ParseRawPrivateKey(pb) 99 | if err == nil { 100 | return key, err 101 | } 102 | 103 | var e *ssh.PassphraseMissingError 104 | if !errors.As(err, &e) && !strings.Contains(err.Error(), errCannotDecodeEncryptedPrivateKeys) { 105 | return nil, err 106 | } 107 | 108 | // simulate ssh client to retry 3 times 109 | for i := 0; i < 3; i++ { 110 | pass, err := promptForPassphrase(file) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | key, err := ssh.ParseRawPrivateKeyWithPassphrase(pb, bytes.TrimSpace(pass)) 116 | if err == nil { 117 | return key, nil 118 | } 119 | 120 | if !errors.Is(err, x509.IncorrectPasswordError) { 121 | return nil, err 122 | } 123 | } 124 | 125 | return nil, &errDescryptingPrivateKey{file} 126 | } 127 | 128 | func promptForPassphrase(file string) ([]byte, error) { 129 | defer fmt.Println("") // clear return 130 | 131 | fmt.Printf("Enter passphrase for key '%s': ", file) 132 | 133 | return term.ReadPassword(int(syscall.Stdin)) 134 | } 135 | -------------------------------------------------------------------------------- /cmd/upterm/command/internal/tui/host_session.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "strings" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | // HostSessionConfirmResult represents the outcome of a confirmation prompt 10 | type HostSessionConfirmResult int 11 | 12 | const ( 13 | // HostSessionConfirmAccepted indicates the user accepted (pressed 'y') 14 | HostSessionConfirmAccepted HostSessionConfirmResult = iota 15 | // HostSessionConfirmRejected indicates the user rejected (pressed 'n') 16 | HostSessionConfirmRejected 17 | // HostSessionConfirmInterrupted indicates the user interrupted (pressed Ctrl+C) 18 | HostSessionConfirmInterrupted 19 | ) 20 | 21 | // HostSessionModel handles both session display and confirmation for the host command. 22 | // It renders the session information and waits for user confirmation (y/n/Ctrl+C) 23 | // unless auto-accept is enabled. 24 | type HostSessionModel struct { 25 | sessionOutput string // Pre-rendered session info 26 | autoAccept bool 27 | state sessionState 28 | result HostSessionConfirmResult 29 | } 30 | 31 | // sessionState represents the current state of the host session prompt 32 | type sessionState int 33 | 34 | const ( 35 | // stateWaitingForConfirm indicates we're displaying the prompt and waiting for user input 36 | stateWaitingForConfirm sessionState = iota 37 | // stateDone indicates a decision has been made and we're ready to quit 38 | stateDone 39 | ) 40 | 41 | // NewHostSessionModel creates a model for displaying session and getting confirmation 42 | func NewHostSessionModel(sessionOutput string, autoAccept bool) HostSessionModel { 43 | initialState := stateWaitingForConfirm 44 | if autoAccept { 45 | initialState = stateDone 46 | } 47 | 48 | return HostSessionModel{ 49 | sessionOutput: sessionOutput, 50 | autoAccept: autoAccept, 51 | state: initialState, 52 | result: HostSessionConfirmAccepted, // default for auto-accept 53 | } 54 | } 55 | 56 | func (m HostSessionModel) Init() tea.Cmd { 57 | // Auto-quit immediately if auto-accept is enabled 58 | if m.autoAccept { 59 | return tea.Quit 60 | } 61 | return nil 62 | } 63 | 64 | func (m HostSessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 65 | // Only handle input when waiting for confirmation 66 | // Note: Context cancellation is handled automatically by tea.Program 67 | if m.state != stateWaitingForConfirm { 68 | return m, nil 69 | } 70 | 71 | switch msg := msg.(type) { 72 | case tea.KeyMsg: 73 | switch msg.String() { 74 | case "y", "Y": 75 | m.result = HostSessionConfirmAccepted 76 | m.state = stateDone 77 | return m, tea.Quit 78 | case "n", "N": 79 | m.result = HostSessionConfirmRejected 80 | m.state = stateDone 81 | return m, tea.Quit 82 | case "ctrl+c": 83 | m.result = HostSessionConfirmInterrupted 84 | m.state = stateDone 85 | return m, tea.Quit 86 | } 87 | } 88 | 89 | return m, nil 90 | } 91 | 92 | func (m HostSessionModel) View() string { 93 | var b strings.Builder 94 | 95 | // Always show the session info 96 | b.WriteString(m.sessionOutput) 97 | 98 | switch m.state { 99 | case stateWaitingForConfirm: 100 | b.WriteString("\n🤝 Accept connections? [y/n] (or to force exit)\n") 101 | 102 | case stateDone: 103 | b.WriteString("\n") 104 | switch m.result { 105 | case HostSessionConfirmAccepted: 106 | b.WriteString("✅ Starting to accept connections...\n") 107 | case HostSessionConfirmRejected: 108 | b.WriteString("❌ Session discarded.\n") 109 | case HostSessionConfirmInterrupted: 110 | b.WriteString("Cancelled by user.\n") 111 | } 112 | } 113 | 114 | return b.String() 115 | } 116 | 117 | // Result returns the confirmation result 118 | func (m HostSessionModel) Result() HostSessionConfirmResult { 119 | return m.result 120 | } 121 | -------------------------------------------------------------------------------- /host/internal/event.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "strings" 8 | 9 | "log/slog" 10 | 11 | "github.com/olebedev/emitter" 12 | "github.com/owenthereal/upterm/upterm" 13 | ) 14 | 15 | const ( 16 | errBadFileDescriptor = "bad file descriptor" 17 | ) 18 | 19 | type terminal struct { 20 | ID string 21 | Pty PTY 22 | Window window 23 | } 24 | 25 | type window struct { 26 | Width int 27 | Height int 28 | } 29 | 30 | type terminalEventEmitter struct { 31 | eventEmitter *emitter.Emitter 32 | } 33 | 34 | func (t terminalEventEmitter) TerminalWindowChanged(id string, pty PTY, w, h int) { 35 | tt := terminal{ 36 | ID: id, 37 | Pty: pty, 38 | Window: window{ 39 | Width: w, 40 | Height: h, 41 | }, 42 | } 43 | t.eventEmitter.Emit(upterm.EventTerminalWindowChanged, tt) 44 | } 45 | 46 | func (t terminalEventEmitter) TerminalDetached(id string, pty PTY) { 47 | tt := terminal{ 48 | ID: id, 49 | Pty: pty, 50 | } 51 | t.eventEmitter.Emit(upterm.EventTerminalDetached, tt) 52 | } 53 | 54 | type terminalEventHandler struct { 55 | eventEmitter *emitter.Emitter 56 | logger *slog.Logger 57 | } 58 | 59 | func (t terminalEventHandler) Handle(ctx context.Context) error { 60 | winCh := t.eventEmitter.On(upterm.EventTerminalWindowChanged, emitter.Sync, emitter.Skip) 61 | dtCh := t.eventEmitter.On(upterm.EventTerminalDetached, emitter.Sync, emitter.Skip) 62 | 63 | defer func() { 64 | t.eventEmitter.Off(upterm.EventTerminalWindowChanged, winCh) 65 | t.eventEmitter.Off(upterm.EventTerminalDetached, dtCh) 66 | }() 67 | 68 | m := make(map[io.ReadWriteCloser]map[string]terminal) 69 | for { 70 | select { 71 | case evt := <-winCh: 72 | if err := t.handleWindowChanged(evt, m); err != nil { 73 | t.logger.Error("error handling window changed", "error", err) 74 | } 75 | case evt := <-dtCh: 76 | if err := t.handleTerminalDetached(evt, m); err != nil { 77 | t.logger.Error("error handling terminal detached", "error", err) 78 | } 79 | case <-ctx.Done(): 80 | return ctx.Err() 81 | } 82 | } 83 | } 84 | 85 | func (t terminalEventHandler) handleWindowChanged(evt emitter.Event, m map[io.ReadWriteCloser]map[string]terminal) error { 86 | args := evt.Args 87 | if len(args) == 0 { 88 | return fmt.Errorf("expect terminal window change event to have at least one argument") 89 | } 90 | 91 | tt, ok := args[0].(terminal) 92 | if !ok { 93 | return fmt.Errorf("expect terminal window change event to receive a terminal") 94 | } 95 | 96 | pty := tt.Pty 97 | ts, ok := m[pty] 98 | if !ok { 99 | ts = make(map[string]terminal) 100 | m[pty] = ts 101 | } 102 | ts[tt.ID] = tt 103 | if err := resizeWindow(pty, ts); err != nil && !strings.Contains(err.Error(), errBadFileDescriptor) { 104 | return fmt.Errorf("error resizing window: %w", err) 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func (t terminalEventHandler) handleTerminalDetached(evt emitter.Event, m map[io.ReadWriteCloser]map[string]terminal) error { 111 | args := evt.Args 112 | if len(args) == 0 { 113 | return fmt.Errorf("expect terminal window change event to have at least one argument") 114 | } 115 | 116 | tt, ok := args[0].(terminal) 117 | if !ok { 118 | return fmt.Errorf("expect terminal window change event to receive a terminal") 119 | } 120 | 121 | pty := tt.Pty 122 | ts, ok := m[pty] 123 | if ok { 124 | delete(ts, tt.ID) 125 | } 126 | 127 | if len(ts) == 0 { 128 | delete(m, pty) 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func resizeWindow(ptmx PTY, ts map[string]terminal) error { 135 | var w, h int 136 | 137 | for _, t := range ts { 138 | if w == 0 || w > t.Window.Width { 139 | w = t.Window.Width 140 | } 141 | 142 | if h == 0 || h > t.Window.Height { 143 | h = t.Window.Height 144 | } 145 | } 146 | 147 | return ptmx.Setsize(h, w) 148 | } 149 | -------------------------------------------------------------------------------- /host/internal/command_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package internal 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | ptylib "github.com/creack/pty" 12 | "github.com/olebedev/emitter" 13 | uio "github.com/owenthereal/upterm/io" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | "golang.org/x/term" 17 | ) 18 | 19 | // TestCommand_Unix_PTY verifies Unix-specific PTY functionality. 20 | // This test validates that a real PTY is properly detected as a TTY 21 | // and stdin forwarding is enabled. 22 | func TestCommand_Unix_PTY(t *testing.T) { 23 | require := require.New(t) 24 | assert := assert.New(t) 25 | 26 | // Create a real PTY 27 | ptmx, tty, err := ptylib.Open() 28 | require.NoError(err, "failed to create PTY") 29 | defer func() { _ = ptmx.Close() }() 30 | defer func() { _ = tty.Close() }() 31 | 32 | // Set PTY size 33 | err = ptylib.Setsize(ptmx, &ptylib.Winsize{Rows: 24, Cols: 80}) 34 | require.NoError(err, "failed to set PTY size") 35 | 36 | // Verify tty IS a terminal 37 | assert.True(term.IsTerminal(int(tty.Fd())), "tty should be recognized as a terminal") 38 | 39 | stdoutr, stdoutw, err := os.Pipe() 40 | require.NoError(err, "failed to create stdout pipe") 41 | defer func() { _ = stdoutr.Close() }() 42 | defer func() { _ = stdoutw.Close() }() 43 | 44 | ee := &emitter.Emitter{} 45 | writers := uio.NewMultiWriter(5) 46 | 47 | // Create command with real PTY (ForceForwardingInputForTesting not needed) 48 | // Use 'head -n 1' which exits immediately after reading one line 49 | // This is more reliable than 'read' which has timing issues with bash initialization 50 | cmd := newCommand( 51 | "head", 52 | []string{"-n", "1"}, 53 | nil, 54 | tty, 55 | stdoutw, 56 | ee, 57 | writers, 58 | false, // Should not be needed for real TTY 59 | ) 60 | 61 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 62 | defer cancel() 63 | 64 | _, err = cmd.Start(ctx) 65 | require.NoError(err, "failed to start command") 66 | 67 | // Capture output in background 68 | outputCh := make(chan string, 1) 69 | go func() { 70 | buf := make([]byte, 1024) 71 | var output []byte 72 | for { 73 | n, err := stdoutr.Read(buf) 74 | if n > 0 { 75 | output = append(output, buf[:n]...) 76 | } 77 | if err != nil { 78 | break 79 | } 80 | } 81 | // Only send if we captured output (don't send empty string) 82 | if len(output) > 0 { 83 | outputCh <- string(output) 84 | } 85 | }() 86 | 87 | // Run the command in a goroutine 88 | errCh := make(chan error, 1) 89 | go func() { 90 | errCh <- cmd.Run() 91 | }() 92 | 93 | // Give head time to start 94 | time.Sleep(100 * time.Millisecond) 95 | 96 | // Send input through the PTY master 97 | // head -n 1 reads one line and exits immediately 98 | testInput := "hello from pty" 99 | _, err = ptmx.Write([]byte(testInput + "\n")) 100 | require.NoError(err, "failed to write to PTY") 101 | 102 | // Wait for command to complete (head exits after reading one line) 103 | select { 104 | case err := <-errCh: 105 | if err != nil { 106 | t.Logf("command completed with error (might be expected): %v", err) 107 | } 108 | case <-time.After(1500 * time.Millisecond): 109 | cancel() 110 | <-errCh 111 | assert.Fail("command did not complete - stdin may not be forwarded for PTY") 112 | return 113 | } 114 | 115 | // Command has exited, now close stdout writer to signal EOF to output reader 116 | _ = stdoutw.Close() 117 | 118 | // Wait for output (should be available now since command has finished) 119 | select { 120 | case output := <-outputCh: 121 | assert.Contains(output, testInput, "should see our input forwarded through PTY and output by head") 122 | case <-time.After(500 * time.Millisecond): 123 | assert.Fail("no output captured - PTY may not be forwarding data correctly") 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /server/cert.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/owenthereal/upterm/upterm" 9 | "golang.org/x/crypto/ssh" 10 | "google.golang.org/protobuf/proto" 11 | ) 12 | 13 | var ( 14 | errCertNotSignedByHost = fmt.Errorf("ssh cert not signed by host") 15 | ) 16 | 17 | type UserCertChecker struct { 18 | UserKeyFallback func(user string, key ssh.PublicKey) (ssh.PublicKey, error) 19 | } 20 | 21 | // Authenticate tries to pass auth request and public key from a cert. 22 | // If the public key is not a cert, it calls the UserKeyFallback func. Otherwise it returns an error. 23 | func (c *UserCertChecker) Authenticate(user string, key ssh.PublicKey) (*AuthRequest, ssh.PublicKey, error) { 24 | cert, ok := key.(*ssh.Certificate) 25 | if !ok { 26 | if c.UserKeyFallback != nil { 27 | key, err := c.UserKeyFallback(user, key) 28 | return nil, key, err 29 | } 30 | 31 | return nil, nil, fmt.Errorf("public key not a cert") 32 | } 33 | 34 | return parseAuthRequestFromCert(user, cert) 35 | } 36 | 37 | // parseAuthRequestFromCert parses auth request and public key from a cert. 38 | // The public key is always the signature key of the cert. 39 | func parseAuthRequestFromCert(principal string, cert *ssh.Certificate) (*AuthRequest, ssh.PublicKey, error) { 40 | key := cert.SignatureKey 41 | 42 | if cert.CertType != ssh.UserCert { 43 | return nil, key, fmt.Errorf("ssh: cert has type %d", cert.CertType) 44 | } 45 | 46 | checker := &ssh.CertChecker{} 47 | if err := checker.CheckCert(principal, cert); err != nil { 48 | return nil, key, err 49 | } 50 | 51 | if len(cert.Extensions) == 0 { 52 | return nil, key, errCertNotSignedByHost 53 | } 54 | 55 | ext, ok := cert.Extensions[upterm.SSHCertExtension] 56 | if !ok { 57 | return nil, key, errCertNotSignedByHost 58 | } 59 | 60 | var auth AuthRequest 61 | if err := proto.Unmarshal([]byte(ext), &auth); err != nil { 62 | return nil, key, err 63 | } 64 | 65 | key, _, _, _, err := ssh.ParseAuthorizedKey(auth.AuthorizedKey) 66 | if err != nil { 67 | return nil, key, fmt.Errorf("error parsing public key from auth request: %w", err) 68 | } 69 | 70 | return &auth, key, nil 71 | } 72 | 73 | type UserCertSigner struct { 74 | SessionID string 75 | User string 76 | AuthRequest *AuthRequest 77 | } 78 | 79 | func (g *UserCertSigner) SignCert(signer ssh.Signer) (ssh.Signer, error) { 80 | b, err := proto.Marshal(g.AuthRequest) 81 | if err != nil { 82 | return nil, fmt.Errorf("error marshaling auth request: %w", err) 83 | } 84 | 85 | at := time.Now() 86 | bt := at.Add(1 * time.Minute) // cert valid for 1 min 87 | cert := &ssh.Certificate{ 88 | Key: signer.PublicKey(), 89 | CertType: ssh.UserCert, 90 | KeyId: g.SessionID, 91 | ValidPrincipals: []string{g.User}, 92 | ValidAfter: uint64(at.Unix()), 93 | ValidBefore: uint64(bt.Unix()), 94 | Permissions: ssh.Permissions{ 95 | Extensions: map[string]string{upterm.SSHCertExtension: string(b)}, 96 | }, 97 | } 98 | 99 | // TODO: use different key to sign 100 | if err := cert.SignCert(rand.Reader, signer); err != nil { 101 | return nil, fmt.Errorf("error signing host cert: %w", err) 102 | } 103 | 104 | cs, err := ssh.NewCertSigner(cert, signer) 105 | if err != nil { 106 | return nil, fmt.Errorf("error generating host signer: %w", err) 107 | } 108 | 109 | return cs, nil 110 | } 111 | 112 | type HostCertSigner struct { 113 | Hostnames []string 114 | } 115 | 116 | func (s *HostCertSigner) SignCert(signer ssh.Signer) (ssh.Signer, error) { 117 | cert := &ssh.Certificate{ 118 | Key: signer.PublicKey(), 119 | CertType: ssh.HostCert, 120 | KeyId: "uptermd", 121 | ValidPrincipals: s.Hostnames, 122 | ValidBefore: ssh.CertTimeInfinity, 123 | } 124 | 125 | if err := cert.SignCert(rand.Reader, signer); err != nil { 126 | return nil, err 127 | } 128 | 129 | return ssh.NewCertSigner(cert, signer) 130 | } 131 | -------------------------------------------------------------------------------- /server/sshproxy_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "net" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/go-kit/kit/metrics/provider" 12 | "github.com/owenthereal/upterm/internal/logging" 13 | "github.com/owenthereal/upterm/routing" 14 | "github.com/owenthereal/upterm/utils" 15 | "github.com/rs/xid" 16 | "golang.org/x/crypto/ssh" 17 | ) 18 | 19 | func Test_sshProxy_dialUpstream(t *testing.T) { 20 | logger := logging.Must(logging.Console(), logging.Debug()).Logger 21 | 22 | signer, err := ssh.ParsePrivateKey([]byte(TestPrivateKeyContent)) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | cs := HostCertSigner{ 28 | Hostnames: []string{"127.0.0.1"}, 29 | } 30 | hostSigner, err := cs.SignCert(signer) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | proxyLn, err := net.Listen("tcp", "127.0.0.1:0") 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | defer func() { 40 | _ = proxyLn.Close() 41 | }() 42 | 43 | proxyAddr := proxyLn.Addr().String() 44 | cd := sidewayConnDialer{ 45 | NodeAddr: proxyAddr, 46 | NeighbourDialer: tcpConnDialer{}, 47 | Logger: logger, 48 | } 49 | proxy := &sshProxy{ 50 | HostSigners: []ssh.Signer{hostSigner}, 51 | Signers: []ssh.Signer{signer}, 52 | SessionManager: newEmbeddedSessionManager(logger), 53 | NodeAddr: proxyAddr, 54 | ConnDialer: cd, 55 | Logger: logger, 56 | MetricsProvider: provider.NewDiscardProvider(), 57 | } 58 | 59 | go func() { 60 | _ = proxy.Serve(proxyLn) 61 | }() 62 | 63 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 64 | defer cancel() 65 | 66 | if err := utils.WaitForServer(ctx, proxyAddr); err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | sshLn, err := net.Listen("tcp", "127.0.0.1:0") 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | defer func() { 75 | _ = sshLn.Close() 76 | }() 77 | 78 | sshdAddr := sshLn.Addr().String() 79 | sshd := &sshd{ 80 | SessionManager: newEmbeddedSessionManager(logger), 81 | HostSigners: []ssh.Signer{signer}, 82 | NodeAddr: sshdAddr, 83 | Logger: logger, 84 | } 85 | 86 | go func() { 87 | _ = sshd.Serve(sshLn) 88 | }() 89 | 90 | if err := utils.WaitForServer(ctx, sshdAddr); err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | encoder := routing.NewEncodeDecoder(routing.ModeEmbedded) 95 | user := encoder.Encode(xid.New().String(), sshdAddr) 96 | ucs, err := testCertSigner(user, signer) 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | 101 | cases := []struct { 102 | Name string 103 | Signer ssh.Signer 104 | }{ 105 | { 106 | Name: "public-key auth", 107 | Signer: signer, 108 | }, 109 | { 110 | Name: "public-key user cert auth", 111 | Signer: ucs, 112 | }, 113 | } 114 | 115 | for _, c := range cases { 116 | cc := c 117 | 118 | t.Run(c.Name, func(t *testing.T) { 119 | config := &ssh.ClientConfig{ 120 | User: user, 121 | Auth: []ssh.AuthMethod{ssh.PublicKeys(cc.Signer)}, 122 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 123 | } 124 | client, err := ssh.Dial("tcp", proxyAddr, config) // proxy to sshd 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | _, err = client.NewSession() 129 | if err == nil || !strings.Contains(err.Error(), "unsupported channel type") { 130 | t.Fatalf("expect unsupported channel type error but got %v", err) 131 | } 132 | }) 133 | } 134 | } 135 | 136 | func testCertSigner(user string, signer ssh.Signer) (ssh.Signer, error) { 137 | cert := &ssh.Certificate{ 138 | Key: signer.PublicKey(), 139 | CertType: ssh.UserCert, 140 | KeyId: "1234", 141 | ValidPrincipals: []string{user}, 142 | ValidBefore: ssh.CertTimeInfinity, 143 | } 144 | 145 | if err := cert.SignCert(rand.Reader, signer); err != nil { 146 | return nil, err 147 | } 148 | 149 | return ssh.NewCertSigner(cert, signer) 150 | } 151 | -------------------------------------------------------------------------------- /etc/man/man1/upterm-host.1: -------------------------------------------------------------------------------- 1 | .nh 2 | .TH "UPTERM" "1" "Nov 2025" "Upterm 0.0.0+dev" "Upterm Manual" 3 | 4 | .SH NAME 5 | upterm-host - Host a terminal session 6 | 7 | 8 | .SH SYNOPSIS 9 | \fBupterm host [flags]\fP 10 | 11 | 12 | .SH DESCRIPTION 13 | Host a terminal session via a reverse SSH tunnel to the Upterm server. 14 | 15 | .PP 16 | The session links the host and client IO to a command's IO. Authentication with the 17 | Upterm server uses private keys in this order: 18 | 1. Private key files: ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, ~/.ssh/id_rsa 19 | 2. SSH Agent keys 20 | 3. Auto-generated ephemeral key (if no keys found) 21 | 22 | .PP 23 | To authorize client connections, use --authorized-keys to specify an authorized_keys file 24 | containing client public keys. 25 | 26 | 27 | .SH OPTIONS 28 | \fB--accept\fP[=false] 29 | Automatically accept client connections without prompts. 30 | 31 | .PP 32 | \fB--authorized-keys\fP="" 33 | Specify a authorize_keys file listing authorized public keys for connection. 34 | 35 | .PP 36 | \fB--codeberg-user\fP=[] 37 | Authorize specified Codeberg users by allowing their public keys to connect. 38 | 39 | .PP 40 | \fB-f\fP, \fB--force-command\fP="" 41 | Enforce a specified command for clients to join, and link the command's input/output to the client's terminal. 42 | 43 | .PP 44 | \fB--github-user\fP=[] 45 | 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. 46 | 47 | .PP 48 | \fB--gitlab-user\fP=[] 49 | Authorize specified GitLab users by allowing their public keys to connect. 50 | 51 | .PP 52 | \fB-h\fP, \fB--help\fP[=false] 53 | help for host 54 | 55 | .PP 56 | \fB--hide-client-ip\fP[=false] 57 | Hide client IP addresses from output (auto-enabled in CI environments). 58 | 59 | .PP 60 | \fB--known-hosts\fP="/Users/owen/.ssh/known_hosts" 61 | Specify a file containing known keys for remote hosts (required). 62 | 63 | .PP 64 | \fB-i\fP, \fB--private-key\fP=[/Users/owen/.ssh/id_ed25519] 65 | Specify private key files for public key authentication with the upterm server (required). 66 | 67 | .PP 68 | \fB-r\fP, \fB--read-only\fP[=false] 69 | Host a read-only session, preventing client interaction. 70 | 71 | .PP 72 | \fB--server\fP="ssh://uptermd.upterm.dev:22" 73 | Specify the upterm server address (required). Supported protocols: ssh, ws, wss. 74 | 75 | .PP 76 | \fB--skip-host-key-check\fP[=false] 77 | 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. 78 | 79 | .PP 80 | \fB--srht-user\fP=[] 81 | Authorize specified SourceHut users by allowing their public keys to connect. 82 | 83 | 84 | .SH OPTIONS INHERITED FROM PARENT COMMANDS 85 | \fB--debug\fP[=false] 86 | enable debug level logging (log file: /home/user/.local/state/upterm/upterm.log). 87 | 88 | 89 | .SH EXAMPLE 90 | .EX 91 | # Host a terminal session running $SHELL, attaching client's IO to the host's: 92 | upterm host 93 | 94 | # Accept client connections automatically without prompts: 95 | upterm host --accept 96 | 97 | # Host a terminal session allowing only specified public key(s) to connect: 98 | upterm host --authorized-keys PATH_TO_AUTHORIZED_KEY_FILE 99 | 100 | # Host a session executing a custom command: 101 | upterm host -- docker run --rm -ti ubuntu bash 102 | 103 | # Host a 'tmux new -t pair-programming' session, forcing clients to join with 'tmux attach -t pair-programming': 104 | upterm host --force-command 'tmux attach -t pair-programming' -- tmux new -t pair-programming 105 | 106 | # Use a different Uptermd server, hosting a session via WebSocket: 107 | upterm host --server wss://YOUR_UPTERMD_SERVER -- YOUR_COMMAND 108 | .EE 109 | 110 | 111 | .SH SEE ALSO 112 | \fBupterm(1)\fP 113 | 114 | 115 | .SH HISTORY 116 | 29-Nov-2025 Auto generated by spf13/cobra 117 | -------------------------------------------------------------------------------- /host/api/api_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | // versions: 3 | // - protoc-gen-go-grpc v1.2.0 4 | // - protoc v3.21.6 5 | // source: api.proto 6 | 7 | package api 8 | 9 | import ( 10 | context "context" 11 | grpc "google.golang.org/grpc" 12 | codes "google.golang.org/grpc/codes" 13 | status "google.golang.org/grpc/status" 14 | ) 15 | 16 | // This is a compile-time assertion to ensure that this generated file 17 | // is compatible with the grpc package it is being compiled against. 18 | // Requires gRPC-Go v1.32.0 or later. 19 | const _ = grpc.SupportPackageIsVersion7 20 | 21 | // AdminServiceClient is the client API for AdminService service. 22 | // 23 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 24 | type AdminServiceClient interface { 25 | GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) 26 | } 27 | 28 | type adminServiceClient struct { 29 | cc grpc.ClientConnInterface 30 | } 31 | 32 | func NewAdminServiceClient(cc grpc.ClientConnInterface) AdminServiceClient { 33 | return &adminServiceClient{cc} 34 | } 35 | 36 | func (c *adminServiceClient) GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) { 37 | out := new(GetSessionResponse) 38 | err := c.cc.Invoke(ctx, "/api.AdminService/GetSession", in, out, opts...) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return out, nil 43 | } 44 | 45 | // AdminServiceServer is the server API for AdminService service. 46 | // All implementations should embed UnimplementedAdminServiceServer 47 | // for forward compatibility 48 | type AdminServiceServer interface { 49 | GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) 50 | } 51 | 52 | // UnimplementedAdminServiceServer should be embedded to have forward compatible implementations. 53 | type UnimplementedAdminServiceServer struct { 54 | } 55 | 56 | func (UnimplementedAdminServiceServer) GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) { 57 | return nil, status.Errorf(codes.Unimplemented, "method GetSession not implemented") 58 | } 59 | 60 | // UnsafeAdminServiceServer may be embedded to opt out of forward compatibility for this service. 61 | // Use of this interface is not recommended, as added methods to AdminServiceServer will 62 | // result in compilation errors. 63 | type UnsafeAdminServiceServer interface { 64 | mustEmbedUnimplementedAdminServiceServer() 65 | } 66 | 67 | func RegisterAdminServiceServer(s grpc.ServiceRegistrar, srv AdminServiceServer) { 68 | s.RegisterService(&AdminService_ServiceDesc, srv) 69 | } 70 | 71 | func _AdminService_GetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 72 | in := new(GetSessionRequest) 73 | if err := dec(in); err != nil { 74 | return nil, err 75 | } 76 | if interceptor == nil { 77 | return srv.(AdminServiceServer).GetSession(ctx, in) 78 | } 79 | info := &grpc.UnaryServerInfo{ 80 | Server: srv, 81 | FullMethod: "/api.AdminService/GetSession", 82 | } 83 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 84 | return srv.(AdminServiceServer).GetSession(ctx, req.(*GetSessionRequest)) 85 | } 86 | return interceptor(ctx, in, info, handler) 87 | } 88 | 89 | // AdminService_ServiceDesc is the grpc.ServiceDesc for AdminService service. 90 | // It's only intended for direct use with grpc.RegisterService, 91 | // and not to be introspected or modified (even as a copy) 92 | var AdminService_ServiceDesc = grpc.ServiceDesc{ 93 | ServiceName: "api.AdminService", 94 | HandlerType: (*AdminServiceServer)(nil), 95 | Methods: []grpc.MethodDesc{ 96 | { 97 | MethodName: "GetSession", 98 | Handler: _AdminService_GetSession_Handler, 99 | }, 100 | }, 101 | Streams: []grpc.StreamDesc{}, 102 | Metadata: "api.proto", 103 | } 104 | -------------------------------------------------------------------------------- /cmd/uptermd/command/root.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | uptermctx "github.com/owenthereal/upterm/internal/context" 9 | "github.com/owenthereal/upterm/internal/logging" 10 | "github.com/owenthereal/upterm/routing" 11 | "github.com/owenthereal/upterm/server" 12 | "github.com/owenthereal/upterm/utils" 13 | "github.com/spf13/cobra" 14 | "github.com/spf13/pflag" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | func Root() *cobra.Command { 19 | rootCmd := &rootCmd{} 20 | cmd := &cobra.Command{ 21 | Use: "uptermd", 22 | Short: "Upterm Daemon", 23 | RunE: rootCmd.RunE, 24 | } 25 | 26 | cmd.PersistentFlags().String("config", "", "server config") 27 | 28 | cmd.PersistentFlags().StringP("ssh-addr", "", utils.DefaultLocalhost("2222"), "ssh server address") 29 | cmd.PersistentFlags().StringP("ws-addr", "", "", "websocket server address") 30 | cmd.PersistentFlags().StringP("node-addr", "", "", "node address") 31 | cmd.PersistentFlags().StringSliceP("private-key", "", nil, "server private key") 32 | cmd.PersistentFlags().StringSliceP("hostname", "", nil, "server hostname for public-key authentication certificate principals. If empty, public-key authentication is used instead.") 33 | 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)") 34 | 35 | cmd.PersistentFlags().StringP("network", "", "mem", "network provider") 36 | cmd.PersistentFlags().StringSliceP("network-opt", "", nil, "network provider option") 37 | 38 | cmd.PersistentFlags().StringP("metric-addr", "", "", "metric server address") 39 | cmd.PersistentFlags().BoolP("debug", "", os.Getenv("DEBUG") != "", "debug") 40 | 41 | cmd.PersistentFlags().String("routing", string(routing.ModeEmbedded), "session routing mode") 42 | cmd.PersistentFlags().String("consul-url", "", "consul URL for routing mode 'consul'") 43 | cmd.PersistentFlags().String("consul-session-ttl", server.DefaultSessionTTL.String(), "consul session TTL for routing mode 'consul'") 44 | 45 | cmd.PersistentFlags().String("sentry-dsn", "", "Sentry DSN for error tracking") 46 | 47 | cmd.AddCommand(versionCmd()) 48 | 49 | return cmd 50 | } 51 | 52 | type rootCmd struct { 53 | } 54 | 55 | func (cmd *rootCmd) RunE(c *cobra.Command, args []string) error { 56 | var opt server.Opt 57 | if err := unmarshalFlags(c, &opt); err != nil { 58 | return err 59 | } 60 | 61 | logOptions := []logging.Option{logging.Console()} 62 | if opt.Debug { 63 | logOptions = append(logOptions, logging.Debug()) 64 | } 65 | if opt.SentryDSN != "" { 66 | logOptions = append(logOptions, logging.Sentry(opt.SentryDSN)) 67 | } 68 | 69 | logger, err := logging.New(logOptions...) 70 | if err != nil { 71 | return err 72 | } 73 | defer func() { 74 | _ = logger.Close() 75 | }() 76 | 77 | c.SetContext(uptermctx.WithLogger(c.Context(), logger)) 78 | 79 | if err := server.Start(c.Context(), opt, logger.Logger); err != nil { 80 | logger.Error("failed to start uptermd", "error", err) 81 | return fmt.Errorf("failed to start uptermd: %w", err) 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func unmarshalFlags(cmd *cobra.Command, opts interface{}) error { 88 | v := viper.New() 89 | 90 | cmd.Flags().VisitAll(func(flag *pflag.Flag) { 91 | flagName := flag.Name 92 | if flagName != "config" && flagName != "help" { 93 | if err := v.BindPFlag(flagName, flag); err != nil { 94 | panic(fmt.Errorf("error binding flag '%s': %w", flagName, err).Error()) 95 | } 96 | } 97 | }) 98 | 99 | v.AutomaticEnv() 100 | v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 101 | v.SetEnvPrefix("UPTERMD") 102 | 103 | cfgFile, err := cmd.Flags().GetString("config") 104 | if err != nil { 105 | return err 106 | } 107 | 108 | if _, err := os.Stat(cfgFile); err == nil { 109 | v.SetConfigFile(cfgFile) 110 | } 111 | 112 | if err := v.ReadInConfig(); err != nil { 113 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 114 | return fmt.Errorf("error loading config file %s: %w", cfgFile, err) 115 | } 116 | } 117 | 118 | return v.Unmarshal(opts) 119 | } 120 | -------------------------------------------------------------------------------- /terraform/digitalocean/charts.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | ingress_nginx_values = { 3 | controller = { 4 | ingressClassResource = { 5 | name = "nginx" 6 | controllerValue : "k8s.io/ingress-nginx" 7 | } 8 | 9 | admissionWebhooks = { 10 | enabled = false 11 | } 12 | 13 | service = { 14 | type = "LoadBalancer" 15 | annotations = { 16 | "service.beta.kubernetes.io/do-loadbalancer-name" = "${var.do_k8s_name}-lb" 17 | "service.beta.kubernetes.io/do-loadbalancer-protocol" = "tcp" 18 | } 19 | } 20 | } 21 | 22 | tcp = { 23 | 22 = "uptermd/uptermd:22" 24 | } 25 | } 26 | 27 | cert_manager_values = { 28 | installCRDs = true 29 | global = { 30 | leaderElection = { 31 | namespace = "cert-manager" 32 | } 33 | } 34 | } 35 | 36 | metrics_server_values = { 37 | extraArgs = { 38 | "kubelet-preferred-address-types" = "InternalIP" 39 | } 40 | } 41 | 42 | uptermd_values = { 43 | image = { 44 | repository = "ghcr.io/owenthereal/upterm/uptermd" 45 | tag = data.github_release.upterm.release_tag 46 | } 47 | autoscaling = { 48 | minReplicas = 2 49 | maxReplicas = 5 50 | } 51 | hostname = var.uptermd_host 52 | websocket = { 53 | enabled = true 54 | ingress_nginx_ingress_class = "nginx" 55 | cert_manager_acme_email = var.uptermd_acme_email 56 | } 57 | host_keys = { 58 | for k, v in var.uptermd_host_keys : 59 | k => v 60 | } 61 | debug = true 62 | } 63 | } 64 | 65 | data "github_release" "upterm" { 66 | owner = "owenthereal" 67 | repository = "upterm" 68 | retrieve_by = "latest" 69 | } 70 | 71 | provider "helm" { 72 | kubernetes { 73 | host = digitalocean_kubernetes_cluster.upterm.endpoint 74 | token = digitalocean_kubernetes_cluster.upterm.kube_config[0].token 75 | cluster_ca_certificate = base64decode(digitalocean_kubernetes_cluster.upterm.kube_config[0].cluster_ca_certificate) 76 | } 77 | } 78 | 79 | resource "helm_release" "ingress_nginx" { 80 | depends_on = [digitalocean_kubernetes_cluster.upterm, local_file.kubeconfig] 81 | name = "ingress-nginx" 82 | chart = "ingress-nginx" 83 | repository = "https://kubernetes.github.io/ingress-nginx" 84 | version = "4.0.16" 85 | namespace = "upterm-ingress-nginx" 86 | wait = var.wait_for_k8s_resources 87 | create_namespace = true 88 | values = [yamlencode(local.ingress_nginx_values)] 89 | } 90 | 91 | resource "helm_release" "cert_manager" { 92 | depends_on = [digitalocean_kubernetes_cluster.upterm, local_file.kubeconfig] 93 | name = "cert-manager" 94 | chart = "cert-manager" 95 | repository = "https://charts.jetstack.io" 96 | version = "1.7.0" 97 | namespace = "cert-manager" 98 | wait = var.wait_for_k8s_resources 99 | create_namespace = true 100 | values = [yamlencode(local.cert_manager_values)] 101 | } 102 | 103 | resource "helm_release" "upterm_metrics_server" { 104 | depends_on = [digitalocean_kubernetes_cluster.upterm, local_file.kubeconfig] 105 | name = "metrics-server" 106 | chart = "metrics-server" 107 | repository = "https://charts.bitnami.com/bitnami" 108 | version = "5.5.1" 109 | namespace = "metrics-server" 110 | wait = var.wait_for_k8s_resources 111 | create_namespace = true 112 | values = [yamlencode(local.metrics_server_values)] 113 | } 114 | 115 | resource "helm_release" "uptermd" { 116 | depends_on = [helm_release.ingress_nginx, helm_release.cert_manager, helm_release.upterm_metrics_server] 117 | name = "uptermd" 118 | chart = "uptermd" 119 | repository = var.uptermd_helm_repo 120 | namespace = "uptermd" 121 | create_namespace = true 122 | wait = var.wait_for_k8s_resources 123 | values = [yamlencode(local.uptermd_values)] 124 | } 125 | -------------------------------------------------------------------------------- /server/sshd.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "sync" 8 | "time" 9 | 10 | "github.com/charmbracelet/ssh" 11 | "github.com/owenthereal/upterm/internal/version" 12 | "github.com/owenthereal/upterm/upterm" 13 | "github.com/owenthereal/upterm/utils" 14 | "log/slog" 15 | gossh "golang.org/x/crypto/ssh" 16 | "google.golang.org/protobuf/proto" 17 | ) 18 | 19 | var ( 20 | serverShutDownDeadline = 1 * time.Second 21 | ) 22 | 23 | type ServerInfo struct { 24 | NodeAddr string 25 | } 26 | 27 | type sshd struct { 28 | SessionManager *SessionManager 29 | HostSigners []gossh.Signer 30 | NodeAddr string 31 | SessionDialListener SessionDialListener 32 | Logger *slog.Logger 33 | 34 | server *ssh.Server 35 | mux sync.Mutex 36 | } 37 | 38 | func (s *sshd) Shutdown() error { 39 | s.mux.Lock() 40 | defer s.mux.Unlock() 41 | 42 | if s.server != nil { 43 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(serverShutDownDeadline)) 44 | defer cancel() 45 | 46 | return s.server.Shutdown(ctx) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func (s *sshd) Serve(ln net.Listener) error { 53 | var signers []ssh.Signer 54 | for _, signer := range s.HostSigners { 55 | signers = append(signers, signer) 56 | } 57 | 58 | sh := newStreamlocalForwardHandler( 59 | s.SessionManager, 60 | s.SessionDialListener, 61 | s.Logger.With("com", "stream-local-handler"), 62 | ) 63 | s.mux.Lock() 64 | s.server = &ssh.Server{ 65 | HostSigners: signers, 66 | Handler: func(s ssh.Session) { 67 | _ = s.Exit(1) // disable ssh login 68 | }, 69 | ConnectionFailedCallback: func(conn net.Conn, err error) { 70 | s.Logger.Error("connection failed", "error", err) 71 | }, 72 | ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig { 73 | config := &gossh.ServerConfig{ 74 | ServerVersion: version.ServerSSHVersion(), 75 | } 76 | return config 77 | }, 78 | ReversePortForwardingCallback: ssh.ReversePortForwardingCallback(func(ctx ssh.Context, host string, port uint32) (granted bool) { 79 | s.Logger.Info("attempt to bind", "tunnel-host", host, "tunnel-port", port) 80 | return true 81 | }), 82 | PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { 83 | checker := UserCertChecker{} 84 | _, _, err := checker.Authenticate(ctx.User(), key) 85 | if err != nil { 86 | s.Logger.Error("error parsing auth request from cert", "error", err) 87 | return false 88 | } 89 | 90 | // TOOD: validate pk 91 | 92 | return true 93 | }, 94 | ChannelHandlers: make(map[string]ssh.ChannelHandler), // disallow channel requests, e.g. shell 95 | RequestHandlers: map[string]ssh.RequestHandler{ 96 | streamlocalForwardChannelType: sh.Handler, 97 | cancelStreamlocalForwardChannelType: sh.Handler, 98 | upterm.ServerCreateSessionRequestType: s.createSessionHandler, 99 | }, 100 | } 101 | s.mux.Unlock() 102 | 103 | return s.server.Serve(ln) 104 | } 105 | 106 | func (s *sshd) createSessionHandler(ctx ssh.Context, srv *ssh.Server, req *gossh.Request) (bool, []byte) { 107 | var sessReq CreateSessionRequest 108 | if err := proto.Unmarshal(req.Payload, &sessReq); err != nil { 109 | return false, []byte(err.Error()) 110 | } 111 | 112 | sessionID := utils.GenerateSessionID() 113 | 114 | // Store complete session data for routing and session management 115 | session := NewSession( 116 | sessionID, 117 | s.NodeAddr, 118 | sessReq.HostUser, 119 | sessReq.HostPublicKeys, 120 | sessReq.ClientAuthorizedKeys, 121 | ) 122 | 123 | sshUser, err := s.SessionManager.CreateSession(session) 124 | if err != nil { 125 | s.Logger.Error("failed to create session", 126 | "error", err, 127 | "session", sessionID, 128 | "node", s.NodeAddr, 129 | ) 130 | return false, []byte(fmt.Sprintf("failed to create session: %v", err)) 131 | } 132 | 133 | sessResp := &CreateSessionResponse{ 134 | SessionID: sessionID, 135 | NodeAddr: s.NodeAddr, 136 | SshUser: sshUser, 137 | } 138 | 139 | b, err := proto.Marshal(sessResp) 140 | if err != nil { 141 | return false, []byte(err.Error()) 142 | } 143 | 144 | return true, b 145 | } 146 | -------------------------------------------------------------------------------- /ftests/host_test.go: -------------------------------------------------------------------------------- 1 | package ftests 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/owenthereal/upterm/host/api" 10 | "github.com/owenthereal/upterm/utils" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "golang.org/x/crypto/ssh" 14 | ) 15 | 16 | func testHostClientCallback(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) { 17 | require := require.New(t) 18 | assert := assert.New(t) 19 | 20 | jch := make(chan *api.Client) 21 | lch := make(chan *api.Client) 22 | 23 | // Setup admin socket 24 | adminSocketFile := setupAdminSocket(t) 25 | 26 | h := &Host{ 27 | Command: getTestShell(), 28 | PrivateKeys: []string{HostPrivateKey}, 29 | AdminSocketFile: adminSocketFile, 30 | PermittedClientPublicKey: ClientPublicKeyContent, 31 | ClientJoinedCallback: func(c *api.Client) { 32 | jch <- c 33 | }, 34 | ClientLeftCallback: func(c *api.Client) { 35 | lch <- c 36 | }, 37 | } 38 | 39 | err := h.Share(hostShareURL) 40 | require.NoError(err) 41 | defer h.Close() 42 | 43 | // verify admin server 44 | session := getAndVerifySession(t, adminSocketFile, hostShareURL, hostNodeAddr) 45 | 46 | ctx, cancel := context.WithCancel(context.Background()) 47 | defer cancel() 48 | 49 | c := &Client{ 50 | PrivateKeys: []string{ClientPrivateKey}, 51 | } 52 | err = c.JoinWithContext(ctx, session, clientJoinURL) 53 | require.NoError(err) 54 | 55 | var clientID string 56 | select { 57 | case cc := <-jch: 58 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(ClientPublicKeyContent)) 59 | require.NoError(err) 60 | 61 | assert.NotEmpty(cc.Id, "client id can't be empty") 62 | clientID = cc.Id 63 | 64 | assert.Equal(utils.FingerprintSHA256(pk), cc.PublicKeyFingerprint, "public key fingerprint should match") 65 | assert.Equal("SSH-2.0-Go", cc.Version, "client version should match") 66 | case <-time.After(2 * time.Second): 67 | t.Fatal("client joined callback is not called") 68 | } 69 | 70 | // client leaves 71 | cancel() 72 | c.Close() 73 | 74 | select { 75 | case cc := <-lch: 76 | assert.NotEmpty(cc.Id, "client id can't be empty") 77 | 78 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(ClientPublicKeyContent)) 79 | require.NoError(err) 80 | 81 | assert.Equal(clientID, cc.Id, "client ID should match on leave") 82 | assert.Equal(utils.FingerprintSHA256(pk), cc.PublicKeyFingerprint, "public key fingerprint should match on leave") 83 | assert.Equal("SSH-2.0-Go", cc.Version, "client version should match on leave") 84 | case <-time.After(2 * time.Second): 85 | if os.Getenv("MUTE_FLAKY_TESTS") != "" { 86 | testLogger.Error("FLAKY_TEST: client left callback is not called") 87 | } else { 88 | t.Fatal("client left callback is not called") 89 | } 90 | } 91 | } 92 | 93 | func testHostSessionCreatedCallback(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) { 94 | require := require.New(t) 95 | assert := assert.New(t) 96 | 97 | // Setup admin socket 98 | adminSocketFile := setupAdminSocket(t) 99 | 100 | h := &Host{ 101 | Command: getTestShell(), 102 | ForceCommand: []string{"vim"}, 103 | PrivateKeys: []string{HostPrivateKey}, 104 | AdminSocketFile: adminSocketFile, 105 | SessionCreatedCallback: func(_ context.Context, session *api.GetSessionResponse) error { 106 | assert.Equal(getTestShell(), session.Command, "command should match") 107 | assert.Equal([]string{"vim"}, session.ForceCommand, "force command should match") 108 | 109 | checkSessionPayload(t, session, hostShareURL, hostNodeAddr) 110 | return nil 111 | }, 112 | } 113 | 114 | err := h.Share(hostShareURL) 115 | require.NoError(err) 116 | defer h.Close() 117 | } 118 | 119 | func testHostFailToShareWithoutPrivateKey(t *testing.T, hostShareURL, hostNodeAddr, clientJoinURL string) { 120 | require := require.New(t) 121 | 122 | // Setup admin socket 123 | adminSocketFile := setupAdminSocket(t) 124 | 125 | h := &Host{ 126 | Command: getTestShell(), 127 | AdminSocketFile: adminSocketFile, 128 | } 129 | err := h.Share(hostShareURL) 130 | require.Error(err, "should fail without private key") 131 | require.ErrorContains(err, "Permission denied (publickey)", "should fail with permission denied error") 132 | } 133 | -------------------------------------------------------------------------------- /server/sshhandler_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/suite" 13 | ) 14 | 15 | type SSHHandlerTestSuite struct { 16 | suite.Suite 17 | } 18 | 19 | func TestSSHHandlerTestSuite(t *testing.T) { 20 | suite.Run(t, new(SSHHandlerTestSuite)) 21 | } 22 | 23 | func (s *SSHHandlerTestSuite) TestIsExpectedShutdownError() { 24 | tests := []struct { 25 | name string 26 | err error 27 | expected bool 28 | }{ 29 | { 30 | name: "nil error", 31 | err: nil, 32 | expected: false, 33 | }, 34 | { 35 | name: "context canceled", 36 | err: context.Canceled, 37 | expected: true, 38 | }, 39 | { 40 | name: "context deadline exceeded", 41 | err: context.DeadlineExceeded, 42 | expected: false, 43 | }, 44 | { 45 | name: "io.EOF", 46 | err: io.EOF, 47 | expected: true, 48 | }, 49 | { 50 | name: "connection closed", 51 | err: errors.New("connection closed"), 52 | expected: true, 53 | }, 54 | { 55 | name: "use of closed network connection", 56 | err: errors.New("use of closed network connection"), 57 | expected: true, 58 | }, 59 | { 60 | name: "connection reset by peer", 61 | err: errors.New("read tcp 127.0.0.1:8080->127.0.0.1:8081: connection reset by peer"), 62 | expected: true, 63 | }, 64 | { 65 | name: "broken pipe", 66 | err: errors.New("write tcp 127.0.0.1:8080->127.0.0.1:8081: broken pipe"), 67 | expected: true, 68 | }, 69 | { 70 | name: "generic network error with connection reset", 71 | err: &net.OpError{Op: "read", Net: "tcp", Err: errors.New("connection reset by peer")}, 72 | expected: true, 73 | }, 74 | { 75 | name: "unexpected error", 76 | err: errors.New("unexpected database error"), 77 | expected: false, 78 | }, 79 | { 80 | name: "authentication failure", 81 | err: errors.New("ssh: handshake failed: authentication failed"), 82 | expected: false, 83 | }, 84 | { 85 | name: "permission denied", 86 | err: errors.New("permission denied"), 87 | expected: false, 88 | }, 89 | } 90 | 91 | for _, tt := range tests { 92 | s.Run(tt.name, func() { 93 | result := isExpectedShutdownError(tt.err) 94 | assert.Equal(s.T(), tt.expected, result, 95 | "isExpectedShutdownError(%v) should return %v", tt.err, tt.expected) 96 | }) 97 | } 98 | } 99 | 100 | func (s *SSHHandlerTestSuite) TestIsExpectedShutdownError_EdgeCases() { 101 | // Test error with "closed" in middle of message 102 | err := errors.New("the connection was closed unexpectedly") 103 | assert.True(s.T(), isExpectedShutdownError(err), 104 | "Expected 'closed' substring to be detected as shutdown error") 105 | 106 | // Test multiple shutdown indicators 107 | err = errors.New("broken pipe: connection closed") 108 | assert.True(s.T(), isExpectedShutdownError(err), 109 | "Expected multiple shutdown indicators to be detected") 110 | 111 | // Test partial matches that will trigger (which is fine for "closed") 112 | err = errors.New("unclosed parenthesis") 113 | assert.True(s.T(), isExpectedShutdownError(err), 114 | "'unclosed' contains 'closed' substring and should match") 115 | 116 | // Test empty error message 117 | err = errors.New("") 118 | assert.False(s.T(), isExpectedShutdownError(err), 119 | "Empty error message should not be expected shutdown error") 120 | } 121 | 122 | func (s *SSHHandlerTestSuite) TestIsExpectedShutdownError_WrappedErrors() { 123 | // Test wrapped context.Canceled 124 | wrappedCanceled := errors.New("operation failed: context canceled") 125 | assert.False(s.T(), isExpectedShutdownError(wrappedCanceled), 126 | "String-wrapped context canceled should not match errors.Is check") 127 | 128 | // Test actual wrapped context.Canceled using fmt.Errorf 129 | actualWrapped := fmt.Errorf("operation failed: %w", context.Canceled) 130 | assert.True(s.T(), isExpectedShutdownError(actualWrapped), 131 | "Properly wrapped context.Canceled should be detected") 132 | 133 | // Test wrapped io.EOF 134 | wrappedEOF := fmt.Errorf("read operation failed: %w", io.EOF) 135 | assert.True(s.T(), isExpectedShutdownError(wrappedEOF), 136 | "Properly wrapped io.EOF should be detected") 137 | } 138 | -------------------------------------------------------------------------------- /host/internal/command.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | 10 | "github.com/oklog/run" 11 | "github.com/olebedev/emitter" 12 | uio "github.com/owenthereal/upterm/io" 13 | "golang.org/x/term" 14 | ) 15 | 16 | func newCommand( 17 | name string, 18 | args []string, 19 | env []string, 20 | stdin *os.File, 21 | stdout *os.File, 22 | eventEmitter *emitter.Emitter, 23 | writers *uio.MultiWriter, 24 | forceForwardingInputForTesting bool, 25 | ) *command { 26 | return &command{ 27 | name: name, 28 | args: args, 29 | env: env, 30 | stdin: stdin, 31 | stdout: stdout, 32 | eventEmitter: eventEmitter, 33 | writers: writers, 34 | forceForwardingInputForTesting: forceForwardingInputForTesting, 35 | } 36 | } 37 | 38 | type command struct { 39 | name string 40 | args []string 41 | env []string 42 | 43 | cmd *exec.Cmd 44 | ptmx PTY 45 | 46 | stdin *os.File 47 | stdout *os.File 48 | 49 | writers *uio.MultiWriter 50 | 51 | eventEmitter *emitter.Emitter 52 | 53 | ctx context.Context 54 | 55 | // ForceForwardingInputForTesting forces stdin forwarding even when stdin is not a TTY. 56 | // This is used in tests where stdin is a pipe but we still want to forward test data. 57 | forceForwardingInputForTesting bool 58 | } 59 | 60 | // setupCommand creates an exec.Cmd with the given context, name, and args. 61 | // No special platform-specific handling is needed - signal handling is done 62 | // at the application level in host/host_*.go files. 63 | func setupCommand(ctx context.Context, name string, args []string) *exec.Cmd { 64 | return exec.CommandContext(ctx, name, args...) 65 | } 66 | 67 | func (c *command) Start(ctx context.Context) (PTY, error) { 68 | c.ctx = ctx 69 | c.cmd = setupCommand(ctx, c.name, c.args) 70 | c.cmd.Env = append(c.env, os.Environ()...) 71 | 72 | var err error 73 | // Pass stdin so startPty can get the initial terminal size 74 | c.ptmx, err = startPty(c.cmd, c.stdin) 75 | if err != nil { 76 | return nil, fmt.Errorf("unable to start pty: %w", err) 77 | } 78 | 79 | return c.ptmx, nil 80 | } 81 | 82 | func (c *command) Run() error { 83 | // Set stdin in raw mode. 84 | isTty := term.IsTerminal(int(c.stdin.Fd())) 85 | 86 | if isTty { 87 | oldState, err := term.MakeRaw(int(c.stdin.Fd())) 88 | if err != nil { 89 | return fmt.Errorf("unable to set terminal to raw mode: %w", err) 90 | } 91 | defer func() { _ = term.Restore(int(c.stdin.Fd()), oldState) }() 92 | } 93 | 94 | var g run.Group 95 | if isTty { 96 | // Setup terminal resize handling (platform-specific) 97 | c.setupTerminalResize(&g, c.stdin, c.ptmx, c.eventEmitter) 98 | } 99 | 100 | // Forward stdin if it's a TTY or if forced for testing. 101 | // Do not forward stdin if it's not a TTY to avoid blocking indefinitely on io.Copy, 102 | // since non-TTY stdin (pipes, redirects) may never receive EOF in daemon-like scenarios. 103 | if isTty || c.forceForwardingInputForTesting { 104 | // input - forward stdin to PTY 105 | ctx, cancel := context.WithCancel(c.ctx) 106 | g.Add(func() error { 107 | _, err := io.Copy(c.ptmx, uio.NewContextReader(ctx, c.stdin)) 108 | return err 109 | }, func(err error) { 110 | cancel() 111 | }) 112 | } 113 | { 114 | // output 115 | if err := c.writers.Append(c.stdout); err != nil { 116 | return err 117 | } 118 | ctx, cancel := context.WithCancel(c.ctx) 119 | g.Add(func() error { 120 | _, err := io.Copy(c.writers, uio.NewContextReader(ctx, c.ptmx)) 121 | return ptyError(err) 122 | }, func(err error) { 123 | c.writers.Remove(os.Stdout) 124 | cancel() 125 | }) 126 | } 127 | { 128 | ctx, cancel := context.WithCancel(c.ctx) 129 | g.Add(func() error { 130 | done := make(chan error, 1) 131 | go func() { 132 | done <- c.ptmx.Wait() 133 | }() 134 | 135 | select { 136 | case err := <-done: 137 | return err 138 | case <-ctx.Done(): 139 | // Context cancelled, kill the process and wait for it to exit 140 | _ = c.ptmx.Kill() 141 | <-done // Wait for the process to actually exit 142 | return ctx.Err() 143 | } 144 | }, func(err error) { 145 | _ = c.ptmx.Close() 146 | cancel() 147 | }) 148 | } 149 | 150 | return g.Run() 151 | } 152 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ 93 | -------------------------------------------------------------------------------- /host/authorizedkeys.go: -------------------------------------------------------------------------------- 1 | package host 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "log/slog" 13 | 14 | "github.com/cli/go-gh/v2/pkg/api" 15 | "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | const ( 19 | codebergKeysUrlFmt = "https://codeberg.org/%s" 20 | gitHubKeysUrlFmt = "https://github.com/%s" 21 | gitLabKeysUrlFmt = "https://gitlab.com/%s" 22 | sourceHutKeysUrlFmt = "https://meta.sr.ht/~%s" 23 | ) 24 | 25 | type AuthorizedKey struct { 26 | PublicKeys []ssh.PublicKey 27 | Comment string 28 | } 29 | 30 | func AuthorizedKeysFromFile(file string) (*AuthorizedKey, error) { 31 | authorizedKeysBytes, err := os.ReadFile(file) 32 | if err != nil { 33 | return nil, nil 34 | } 35 | 36 | return parseAuthorizedKeys(authorizedKeysBytes, file) 37 | } 38 | 39 | func CodebergUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, error) { 40 | return usersPublicKeys(codebergKeysUrlFmt, usernames) 41 | } 42 | 43 | func GitHubUserAuthorizedKeys(usernames []string, logger *slog.Logger) ([]*AuthorizedKey, error) { 44 | var ( 45 | authorizedKeys []*AuthorizedKey 46 | seen = make(map[string]bool) 47 | ) 48 | for _, username := range usernames { 49 | if _, found := seen[username]; !found { 50 | seen[username] = true 51 | 52 | pks, err := githubUserPublicKeys(username, logger) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | aks, err := parseAuthorizedKeys(pks, username) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | authorizedKeys = append(authorizedKeys, aks) 63 | } 64 | } 65 | 66 | return authorizedKeys, nil 67 | } 68 | 69 | func GitLabUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, error) { 70 | return usersPublicKeys(gitLabKeysUrlFmt, usernames) 71 | } 72 | 73 | func SourceHutUserAuthorizedKeys(usernames []string) ([]*AuthorizedKey, error) { 74 | return usersPublicKeys(sourceHutKeysUrlFmt, usernames) 75 | } 76 | 77 | func parseAuthorizedKeys(keysBytes []byte, comment string) (*AuthorizedKey, error) { 78 | var authorizedKeys []ssh.PublicKey 79 | for len(keysBytes) > 0 { 80 | pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(keysBytes) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | authorizedKeys = append(authorizedKeys, pubKey) 86 | keysBytes = rest 87 | } 88 | 89 | return &AuthorizedKey{ 90 | PublicKeys: authorizedKeys, 91 | Comment: comment, 92 | }, nil 93 | } 94 | 95 | func githubUserPublicKeys(username string, logger *slog.Logger) ([]byte, error) { 96 | client, err := api.DefaultRESTClient() 97 | if err != nil { 98 | if strings.Contains(err.Error(), "authentication token not found for host") { 99 | // fallback to use the public GH API 100 | logger.Warn("no GitHub token found, falling back to public API", "error", err) 101 | return userPublicKeys(gitHubKeysUrlFmt, username) 102 | } 103 | 104 | return nil, err 105 | } 106 | 107 | keys := []struct { 108 | Key string `json:"key"` 109 | }{} 110 | if err := client.Get(fmt.Sprintf("users/%s/keys", url.PathEscape(username)), &keys); err != nil { 111 | return nil, err 112 | } 113 | 114 | var authorizedKeys []string 115 | for _, key := range keys { 116 | authorizedKeys = append(authorizedKeys, key.Key) 117 | } 118 | 119 | return []byte(strings.Join(authorizedKeys, "\n")), nil 120 | } 121 | 122 | func usersPublicKeys(urlFmt string, usernames []string) ([]*AuthorizedKey, error) { 123 | var ( 124 | authorizedKeys []*AuthorizedKey 125 | seen = make(map[string]bool) 126 | ) 127 | for _, username := range usernames { 128 | if _, found := seen[username]; !found { 129 | seen[username] = true 130 | 131 | keyBytes, err := userPublicKeys(urlFmt, username) 132 | if err != nil { 133 | return nil, fmt.Errorf("[%s]: %s", username, err) 134 | } 135 | userKeys, err := parseAuthorizedKeys(keyBytes, username) 136 | if err != nil { 137 | return nil, fmt.Errorf("[%s]: %s", username, err) 138 | } 139 | 140 | authorizedKeys = append(authorizedKeys, userKeys) 141 | } 142 | } 143 | return authorizedKeys, nil 144 | } 145 | 146 | func userPublicKeys(urlFmt string, username string) ([]byte, error) { 147 | path := url.PathEscape(fmt.Sprintf("%s.keys", username)) 148 | 149 | client := http.Client{ 150 | Timeout: 5 * time.Second, 151 | } 152 | resp, err := client.Get(fmt.Sprintf(urlFmt, path)) 153 | if err != nil { 154 | return nil, err 155 | } 156 | defer func() { 157 | _ = resp.Body.Close() 158 | }() 159 | 160 | return io.ReadAll(resp.Body) 161 | } 162 | -------------------------------------------------------------------------------- /cmd/upterm/command/upgrade.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | "time" 10 | 11 | ggh "github.com/google/go-github/v48/github" 12 | uptermctx "github.com/owenthereal/upterm/internal/context" 13 | "github.com/owenthereal/upterm/internal/version" 14 | "github.com/spf13/cobra" 15 | "github.com/tj/go-update" 16 | "github.com/tj/go-update/progress" 17 | "github.com/tj/go/term" 18 | ) 19 | 20 | func upgradeCmd() *cobra.Command { 21 | cmd := &cobra.Command{ 22 | Use: "upgrade", 23 | Short: "Upgrade the CLI", 24 | Example: ` # Upgrade to the latest version: 25 | upterm upgrade 26 | 27 | # Upgrade to a specific version: 28 | upterm upgrade 0.2.0`, 29 | RunE: upgradeRunE, 30 | } 31 | 32 | return cmd 33 | } 34 | 35 | func upgradeRunE(c *cobra.Command, args []string) error { 36 | logger := uptermctx.Logger(c.Context()) 37 | if logger == nil { 38 | return fmt.Errorf("logger not available") 39 | } 40 | 41 | term.HideCursor() 42 | defer term.ShowCursor() 43 | 44 | m := &update.Manager{ 45 | Command: "upterm", 46 | Store: &store{ 47 | Owner: "owenthereal", 48 | Repo: "upterm", 49 | Version: version.String(), 50 | }, 51 | } 52 | 53 | var r release 54 | if len(args) > 0 { 55 | rr, err := m.GetRelease(trimVPrefix(args[0])) 56 | if err != nil { 57 | return fmt.Errorf("error fetching release: %s", err) 58 | } 59 | 60 | r = release{rr} 61 | } else { 62 | // fetch the new releases 63 | releases, err := m.LatestReleases() 64 | if err != nil { 65 | logger.Error("error fetching releases", "error", err) 66 | return fmt.Errorf("error fetching releases: %w", err) 67 | } 68 | 69 | // no updates 70 | if len(releases) == 0 { 71 | return fmt.Errorf("no updates") 72 | } 73 | 74 | // latest release 75 | r = release{releases[0]} 76 | } 77 | 78 | if version.String() == trimVPrefix(r.Version) { 79 | fmt.Println("Upterm is up-to-date") 80 | return nil 81 | } 82 | 83 | // find the tarball for this system 84 | a := r.FindTarballWithVersion(runtime.GOOS, runtime.GOARCH) 85 | if a == nil { 86 | return fmt.Errorf("no binary for your system") 87 | } 88 | 89 | // download tarball to a tmp dir 90 | tarball, err := a.DownloadProxy(progress.Reader) 91 | if err != nil { 92 | return fmt.Errorf("error downloading: %s", err) 93 | } 94 | 95 | // install it 96 | if err := m.Install(tarball); err != nil { 97 | return fmt.Errorf("error installing: %s", err) 98 | } 99 | 100 | fmt.Printf("Upgraded upterm %s to %s\n", version.String(), trimVPrefix(r.Version)) 101 | return nil 102 | } 103 | 104 | func trimVPrefix(s string) string { 105 | return strings.TrimPrefix(s, "v") 106 | } 107 | 108 | type release struct { 109 | *update.Release 110 | } 111 | 112 | func (r *release) FindTarballWithVersion(os, arch string) *update.Asset { 113 | s := fmt.Sprintf("%s_%s", os, arch) 114 | for _, a := range r.Assets { 115 | ext := filepath.Ext(a.Name) 116 | if strings.Contains(a.Name, s) && ext == ".gz" { 117 | return a 118 | } 119 | } 120 | 121 | return nil 122 | } 123 | 124 | type store struct { 125 | Owner string 126 | Repo string 127 | Version string 128 | } 129 | 130 | func (s *store) GetRelease(version string) (*update.Release, error) { 131 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 132 | defer cancel() 133 | 134 | gh := ggh.NewClient(nil) 135 | 136 | r, res, err := gh.Repositories.GetReleaseByTag(ctx, s.Owner, s.Repo, "v"+version) 137 | 138 | if res.StatusCode == 404 { 139 | return nil, update.ErrNotFound 140 | } 141 | 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | return githubRelease(r), nil 147 | } 148 | 149 | func (s *store) LatestReleases() ([]*update.Release, error) { 150 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 151 | defer cancel() 152 | 153 | gh := ggh.NewClient(nil) 154 | 155 | r, _, err := gh.Repositories.GetLatestRelease(ctx, s.Owner, s.Repo) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | return []*update.Release{ 161 | githubRelease(r), 162 | }, nil 163 | } 164 | 165 | func githubRelease(r *ggh.RepositoryRelease) *update.Release { 166 | out := &update.Release{ 167 | Version: r.GetTagName(), 168 | Notes: r.GetBody(), 169 | PublishedAt: r.GetPublishedAt().Time, 170 | URL: r.GetURL(), 171 | } 172 | 173 | for _, a := range r.Assets { 174 | out.Assets = append(out.Assets, &update.Asset{ 175 | Name: a.GetName(), 176 | Size: a.GetSize(), 177 | URL: a.GetBrowserDownloadURL(), 178 | Downloads: a.GetDownloadCount(), 179 | }) 180 | } 181 | 182 | return out 183 | } 184 | -------------------------------------------------------------------------------- /internal/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | 12 | "github.com/getsentry/sentry-go" 13 | slogsentry "github.com/getsentry/sentry-go/slog" 14 | "github.com/owenthereal/upterm/internal/version" 15 | slogmulti "github.com/samber/slog-multi" 16 | ) 17 | 18 | const ( 19 | sentryFlushTimeout = 2 * time.Second 20 | ) 21 | 22 | // Logger wraps slog.Logger with cleanup capability and dynamic handlers 23 | type Logger struct { 24 | *slog.Logger 25 | cleanupFuncs []func() error 26 | } 27 | 28 | // Close cleans up resources 29 | func (l *Logger) Close() error { 30 | for _, cleanup := range l.cleanupFuncs { 31 | if err := cleanup(); err != nil { 32 | return err 33 | } 34 | } 35 | return nil 36 | } 37 | 38 | // With returns a new logger with additional attributes 39 | func (l *Logger) With(args ...any) *Logger { 40 | return &Logger{ 41 | Logger: l.Logger.With(args...), 42 | cleanupFuncs: l.cleanupFuncs, 43 | } 44 | } 45 | 46 | // WithGroup returns a new logger with a group 47 | func (l *Logger) WithGroup(name string) *Logger { 48 | return &Logger{ 49 | Logger: l.Logger.WithGroup(name), 50 | cleanupFuncs: l.cleanupFuncs, 51 | } 52 | } 53 | 54 | // Option configures a logger 55 | type Option func(*config) error 56 | 57 | type config struct { 58 | level slog.Level 59 | outputs []io.Writer 60 | handlers []slog.Handler 61 | cleanupFuncs []func() error 62 | } 63 | 64 | // New creates a logger with options (for upterm client) 65 | func New(opts ...Option) (*Logger, error) { 66 | cfg := &config{ 67 | level: slog.LevelInfo, 68 | } 69 | for _, opt := range opts { 70 | if err := opt(cfg); err != nil { 71 | return nil, err 72 | } 73 | } 74 | 75 | // Default to stderr if no outputs specified 76 | if len(cfg.outputs) == 0 { 77 | cfg.outputs = []io.Writer{os.Stderr} 78 | } 79 | 80 | // Always add JSON handler for normal logging 81 | cfg.handlers = append(cfg.handlers, slog.NewJSONHandler(io.MultiWriter(cfg.outputs...), &slog.HandlerOptions{Level: cfg.level})) 82 | 83 | return &Logger{ 84 | Logger: slog.New(slogmulti.Fanout(cfg.handlers...)), 85 | cleanupFuncs: cfg.cleanupFuncs, 86 | }, nil 87 | } 88 | 89 | // Must wraps NewWithOptions and panics on error 90 | func Must(opts ...Option) *Logger { 91 | logger, err := New(opts...) 92 | if err != nil { 93 | panic(err) 94 | } 95 | return logger 96 | } 97 | 98 | // Level sets the log level 99 | func Level(level slog.Level) Option { 100 | return func(c *config) error { 101 | c.level = level 102 | return nil 103 | } 104 | } 105 | 106 | // Debug sets debug level 107 | func Debug() Option { 108 | return Level(slog.LevelDebug) 109 | } 110 | 111 | // Console logs to stderr 112 | func Console() Option { 113 | return func(c *config) error { 114 | c.outputs = append(c.outputs, os.Stderr) 115 | return nil 116 | } 117 | } 118 | 119 | // File logs to a file (path is required) 120 | func File(path string) Option { 121 | return func(c *config) error { 122 | if path == "" { 123 | return fmt.Errorf("log file path is required") 124 | } 125 | 126 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 127 | return fmt.Errorf("failed to create log directory: %w", err) 128 | } 129 | 130 | file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 131 | if err != nil { 132 | return fmt.Errorf("failed to open log file %q: %w", path, err) 133 | } 134 | 135 | c.outputs = append(c.outputs, file) 136 | c.cleanupFuncs = append(c.cleanupFuncs, file.Close) 137 | return nil 138 | } 139 | } 140 | 141 | // Sentry enables Sentry error reporting 142 | func Sentry(dsn string) Option { 143 | return func(c *config) error { 144 | if dsn == "" { 145 | return nil 146 | } 147 | 148 | sentryHandler, cleanup, err := newSentryHandler(dsn) 149 | if err != nil { 150 | return err 151 | } 152 | c.handlers = append(c.handlers, sentryHandler) 153 | c.cleanupFuncs = append(c.cleanupFuncs, cleanup) 154 | return nil 155 | } 156 | } 157 | 158 | func newSentryHandler(dsn string) (slog.Handler, func() error, error) { 159 | err := sentry.Init(sentry.ClientOptions{ 160 | Dsn: dsn, 161 | Environment: "production", 162 | Release: version.Version, 163 | AttachStacktrace: true, 164 | }) 165 | if err != nil { 166 | return nil, nil, err 167 | } 168 | 169 | handler := slogsentry.Option{ 170 | Level: slog.LevelError, 171 | }.NewSentryHandler(context.Background()) 172 | 173 | cleanup := func() error { 174 | ok := sentry.Flush(sentryFlushTimeout) 175 | if !ok { 176 | return fmt.Errorf("sentry flush timeout") 177 | } 178 | return nil 179 | } 180 | 181 | return handler, cleanup, nil 182 | } 183 | -------------------------------------------------------------------------------- /server/network.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/owenthereal/upterm/memlistener" 10 | "github.com/rs/xid" 11 | ) 12 | 13 | var networks networkProviders 14 | 15 | func init() { 16 | networks = []NetworkProvider{&UnixProvider{}, &MemoryProvider{}} 17 | } 18 | 19 | type networkProviders []NetworkProvider 20 | 21 | func (n networkProviders) Get(name string) NetworkProvider { 22 | for _, p := range n { 23 | if p.Name() == name { 24 | return p 25 | } 26 | } 27 | 28 | return nil 29 | } 30 | 31 | type NetworkProvider interface { 32 | SetOpts(opts NetworkOptions) error 33 | Session() SessionDialListener 34 | SSHD() SSHDDialListener 35 | Name() string 36 | Opts() string 37 | } 38 | 39 | type NetworkOptions map[string]string 40 | 41 | type SessionDialListener interface { 42 | Listen(sesisonID string) (net.Listener, error) 43 | Dial(sessionID string) (net.Conn, error) 44 | } 45 | 46 | type SSHDDialListener interface { 47 | Listen() (net.Listener, error) 48 | Dial() (net.Conn, error) 49 | } 50 | 51 | type MemoryProvider struct { 52 | SocketPath string 53 | memln *memlistener.MemoryListener 54 | } 55 | 56 | func (p *MemoryProvider) Name() string { 57 | return "mem" 58 | } 59 | 60 | func (p *MemoryProvider) Opts() string { 61 | return fmt.Sprintf("ssh-socket-path=%s", p.SocketPath) 62 | } 63 | 64 | func (p *MemoryProvider) SetOpts(opts NetworkOptions) error { 65 | p.SocketPath = xid.New().String() 66 | p.memln = memlistener.New() 67 | return nil 68 | } 69 | 70 | func (p *MemoryProvider) Session() SessionDialListener { 71 | return &memorySessionDialListener{memln: p.memln} 72 | } 73 | 74 | func (p *MemoryProvider) SSHD() SSHDDialListener { 75 | return &memorySSHDDialListener{socketPath: p.SocketPath, memln: p.memln} 76 | } 77 | 78 | type memorySSHDDialListener struct { 79 | socketPath string 80 | memln *memlistener.MemoryListener 81 | } 82 | 83 | func (l *memorySSHDDialListener) Listen() (net.Listener, error) { 84 | return l.memln.Listen("mem", l.socketPath) 85 | } 86 | 87 | func (l *memorySSHDDialListener) Dial() (net.Conn, error) { 88 | return l.memln.Dial("mem", l.socketPath) 89 | } 90 | 91 | type memorySessionDialListener struct { 92 | memln *memlistener.MemoryListener 93 | } 94 | 95 | func (d *memorySessionDialListener) Listen(sessionID string) (net.Listener, error) { 96 | return d.memln.Listen("mem", sessionID) 97 | } 98 | 99 | func (d *memorySessionDialListener) Dial(sessionID string) (net.Conn, error) { 100 | return d.memln.Dial("mem", sessionID) 101 | } 102 | 103 | type UnixProvider struct { 104 | sessionSocketDir string 105 | sshdSocketPath string 106 | } 107 | 108 | func (p *UnixProvider) Opts() string { 109 | return fmt.Sprintf("session-socket-dir=%s,sshd-socket-path=%s", p.sessionSocketDir, p.sshdSocketPath) 110 | } 111 | 112 | func (p *UnixProvider) SetOpts(opts NetworkOptions) error { 113 | var ok bool 114 | p.sessionSocketDir, ok = opts["session-socket-dir"] 115 | if !ok { 116 | dir, err := os.MkdirTemp("", "uptermd") 117 | if err != nil { 118 | return fmt.Errorf("missing \"session-socket-dir\" option for network provider %s", p.Name()) 119 | } 120 | 121 | p.sessionSocketDir = dir 122 | } 123 | p.sshdSocketPath, ok = opts["sshd-socket-path"] 124 | if !ok { 125 | dir, err := os.MkdirTemp("", "uptermd") 126 | if err != nil { 127 | return fmt.Errorf("missing \"sshd-socket-path\" option for network provider %s", p.Name()) 128 | } 129 | 130 | p.sshdSocketPath = filepath.Join(dir, "sshd.sock") 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func (p *UnixProvider) Session() SessionDialListener { 137 | return &unixSessionDialListener{SocketDir: p.sessionSocketDir} 138 | } 139 | 140 | func (p *UnixProvider) SSHD() SSHDDialListener { 141 | return &unixSSHDDialListener{SocketPath: p.sshdSocketPath} 142 | } 143 | 144 | func (p *UnixProvider) Name() string { 145 | return "unix" 146 | } 147 | 148 | type unixSSHDDialListener struct { 149 | SocketPath string 150 | } 151 | 152 | func (d *unixSSHDDialListener) Listen() (net.Listener, error) { 153 | return net.Listen("unix", d.SocketPath) 154 | } 155 | 156 | func (d *unixSSHDDialListener) Dial() (net.Conn, error) { 157 | return net.Dial("unix", d.SocketPath) 158 | } 159 | 160 | type unixSessionDialListener struct { 161 | SocketDir string 162 | } 163 | 164 | func (d *unixSessionDialListener) Listen(sessionID string) (net.Listener, error) { 165 | return net.Listen("unix", d.socketPath(sessionID)) 166 | } 167 | 168 | func (d *unixSessionDialListener) Dial(sessionID string) (net.Conn, error) { 169 | return net.Dial("unix", d.socketPath(sessionID)) 170 | } 171 | 172 | func (d *unixSessionDialListener) socketPath(sessionID string) string { 173 | return filepath.Join(d.SocketDir, sessionID+".sock") 174 | } 175 | -------------------------------------------------------------------------------- /host/internal/reversetunnel.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | "net/url" 9 | "os/user" 10 | "strings" 11 | "time" 12 | 13 | "github.com/owenthereal/upterm/server" 14 | "github.com/owenthereal/upterm/upterm" 15 | "github.com/owenthereal/upterm/ws" 16 | "golang.org/x/crypto/ssh" 17 | "google.golang.org/protobuf/proto" 18 | ) 19 | 20 | const ( 21 | publickeyAuthError = "ssh: unable to authenticate, attempted methods [none]" 22 | ) 23 | 24 | type ReverseTunnel struct { 25 | *ssh.Client 26 | 27 | Host *url.URL 28 | Signers []ssh.Signer 29 | AuthorizedKeys []ssh.PublicKey 30 | KeepAliveDuration time.Duration 31 | HostKeyCallback ssh.HostKeyCallback 32 | Logger *slog.Logger 33 | 34 | ln net.Listener 35 | } 36 | 37 | func (c *ReverseTunnel) Close() { 38 | _ = c.ln.Close() 39 | _ = c.Client.Close() 40 | } 41 | 42 | func (c *ReverseTunnel) Listener() net.Listener { 43 | return c.ln 44 | } 45 | 46 | func (c *ReverseTunnel) Establish(ctx context.Context) (*server.CreateSessionResponse, error) { 47 | user, err := user.Current() 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | baseLogger := c.Logger 53 | if baseLogger == nil { 54 | baseLogger = slog.Default() 55 | } 56 | 57 | var ( 58 | auths []ssh.AuthMethod 59 | publicKeys [][]byte 60 | authorizedKeys [][]byte 61 | ) 62 | for _, signer := range c.Signers { 63 | auths = append(auths, ssh.PublicKeys(signer)) 64 | publicKeys = append(publicKeys, ssh.MarshalAuthorizedKey(signer.PublicKey())) 65 | } 66 | for _, ak := range c.AuthorizedKeys { 67 | authorizedKeys = append(authorizedKeys, ssh.MarshalAuthorizedKey(ak)) 68 | } 69 | 70 | config := &ssh.ClientConfig{ 71 | User: user.Username, 72 | Auth: auths, 73 | ClientVersion: upterm.HostSSHClientVersion, 74 | // Enforce a restricted set of algorithms for security 75 | // TODO: make this configurable if necessary 76 | HostKeyAlgorithms: []string{ 77 | ssh.CertAlgoED25519v01, 78 | ssh.CertAlgoRSASHA512v01, 79 | ssh.CertAlgoRSASHA256v01, 80 | ssh.KeyAlgoED25519, 81 | ssh.KeyAlgoRSASHA512, 82 | ssh.KeyAlgoRSASHA256, 83 | }, 84 | HostKeyCallback: c.HostKeyCallback, 85 | } 86 | 87 | if isWSScheme(c.Host.Scheme) { 88 | u, _ := url.Parse(c.Host.String()) // clone 89 | u.User = url.UserPassword(user.Username, "") 90 | c.Client, err = ws.NewSSHClient(u, config, false) 91 | } else { 92 | c.Client, err = ssh.Dial("tcp", c.Host.Host, config) 93 | } 94 | 95 | if err != nil { 96 | return nil, sshDialError(c.Host.String(), err) 97 | } 98 | 99 | sessResp, err := c.createSession(user.Username, publicKeys, authorizedKeys) 100 | if err != nil { 101 | return nil, fmt.Errorf("error creating session: %w", err) 102 | } 103 | 104 | c.ln, err = c.Listen("unix", sessResp.SessionID) 105 | if err != nil { 106 | return nil, fmt.Errorf("unable to create reverse tunnel: %w", err) 107 | } 108 | 109 | // make sure connection is alive 110 | go keepAlive(ctx, c.KeepAliveDuration, func() { 111 | // TODO: ping with session ID 112 | _, _, err := c.SendRequest(upterm.OpenSSHKeepAliveRequestType, true, nil) 113 | if err != nil { 114 | baseLogger.Error("error pinging server", "error", err) 115 | } 116 | }) 117 | 118 | return sessResp, nil 119 | } 120 | 121 | func (c *ReverseTunnel) createSession(user string, hostPublicKeys [][]byte, clientAuthorizedKeys [][]byte) (*server.CreateSessionResponse, error) { 122 | req := &server.CreateSessionRequest{ 123 | HostUser: user, 124 | HostPublicKeys: hostPublicKeys, 125 | ClientAuthorizedKeys: clientAuthorizedKeys, 126 | } 127 | b, err := proto.Marshal(req) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | ok, body, err := c.SendRequest(upterm.ServerCreateSessionRequestType, true, b) 133 | if err != nil { 134 | return nil, fmt.Errorf("error initializing session: %w", err) 135 | } 136 | if !ok { 137 | return nil, fmt.Errorf("could not initialize session: %s", body) 138 | } 139 | 140 | var resp server.CreateSessionResponse 141 | if err := proto.Unmarshal(body, &resp); err != nil { 142 | return nil, fmt.Errorf("error unmarshaling created session: %w", err) 143 | } 144 | 145 | return &resp, nil 146 | } 147 | 148 | func keepAlive(ctx context.Context, d time.Duration, fn func()) { 149 | ticker := time.NewTicker(d) 150 | defer ticker.Stop() 151 | 152 | for { 153 | select { 154 | case <-ctx.Done(): 155 | return 156 | case <-ticker.C: 157 | fn() 158 | } 159 | } 160 | } 161 | 162 | func isWSScheme(scheme string) bool { 163 | return scheme == "ws" || scheme == "wss" 164 | } 165 | 166 | type PermissionDeniedError struct { 167 | host string 168 | err error 169 | } 170 | 171 | func (e *PermissionDeniedError) Error() string { 172 | return fmt.Sprintf("%s: Permission denied (publickey).", e.host) 173 | } 174 | 175 | func (e *PermissionDeniedError) Unwrap() error { return e.err } 176 | 177 | func sshDialError(host string, err error) error { 178 | if strings.Contains(err.Error(), publickeyAuthError) { 179 | return &PermissionDeniedError{ 180 | host: host, 181 | err: err, 182 | } 183 | } 184 | 185 | return fmt.Errorf("ssh dial error: %w", err) 186 | } 187 | --------------------------------------------------------------------------------