Topic:
gossh-alternativewebsocketptyremote-shellterminaldevopstermux— SSH-like PTY sessions without the SSH protocol.
HSSH is an SSH-like remote terminal written in Go. The transport is not the
SSH protocol: the client and host talk HTTP handshake + WebSocket, and the
host runs a real shell inside a real PTY (native POSIX PTY, ConPTY on
Windows). The client never emulates a terminal — it puts the local tty into
raw mode and passes bytes through, so vim, top, colours and UTF-8 work
exactly as they would over SSH.
┌──────────┐ HTTP GET /health (discovery) ┌──────────┐
│ hssh │ ────────────────────────────────▶ │ hssh │
│ connect │ WebSocket /connect (hssh.v1) │ host │
│ (client) │ ◀════════════════════════════════ │ (server) │
└──────────┘ binary frames = PTY bytes └──────────┘
text frames = JSON control │ PTY
▼ real shell
- Real PTY + real shell (
bash/zsh/shauto-detected, override with--shellorHSSH_SHELL). No pipe fallback — unsupported platforms get an error. - Password or token auth, TLS (
https/wss), plaintext credential refusal on both ends (client never sends secrets overws://, nor to anAuthNonehost). - Session table (
hssh sessions), per-session working-directory isolation under~/.hssh/sessions, per-session history under~/.hssh/history, idle/session timeouts, heartbeat keepalive. - Idle sessions are never killed as “slow”: slow-client detection requires a backed-up output queue.
- Session resume:
hssh host --allow-resume+hssh connect --session=<id>. - Strict flags: unknown flags are errors, never silently ignored. Flags win over
HSSH_*env over JSON file. - Structured logs with secret redaction.
Requires Go 1.27.1+ (go.mod).
git clone https://github.com/Codeleafly/hssh.git hssh
cd hssh
go build -o bin/hssh ./cmd/hsshOr download a versioned binary from Releases (see below).
Windows cross-compile:
GOOS=windows go build ./...Terminal 1 — start the host:
./bin/hssh host --port 8080 --allow-unauthenticatedTerminal 2 — connect:
./bin/hssh connect=http://127.0.0.1:8080Inside the session Ctrl+C/Ctrl+D go to the remote shell.
Disconnect cleanly with Ctrl+] or ~. typed at the start of a line.
Window resizes are forwarded automatically.
./bin/hssh host --port 8080 --allow-unauthenticated
./bin/hssh host --port 8443 --tls-cert server.crt --tls-key server.key --password 's3cret'
./bin/hssh host --port 8443 --tls-cert server.crt --tls-key server.key --token "$(./bin/hssh token)"| Flag | Default | Meaning |
|---|---|---|
--host |
0.0.0.0 |
Bind address |
--port / -p |
8080 |
Listen port |
--shell / -s |
auto | Shell to run: HSSH_SHELL → $SHELL → bash/zsh/fish/ash/dash/sh (Windows: pwsh/powershell/%COMSPEC%) |
--workdir / -w |
home | Initial working directory |
--password / -k |
— | Require a password (client must use https/wss) |
--token / -t |
— | Require a token |
--tls-cert / --tls-key |
— | Serve HTTPS/WSS (must be given together) |
--allow-unauthenticated |
false | Skip the no-auth confirmation prompt |
--allow-resume |
false | Let clients reattach with --session=<id> |
--per-session-cwd |
false | Private working directory per session |
--max-sessions |
0 (= unlimited) |
Reject new sessions above this count |
--idle-timeout |
off | e.g. 30m, 90s, or bare seconds (90) — close sessions with no input |
--session-timeout |
off | Absolute max session lifetime from creation (e.g. 2h) |
--heartbeat |
30s |
Keepalive interval |
--output-buffer |
4M |
Per-session output queue ceiling (512K, 2M, 4MiB, 1G) |
--log-level |
info |
debug, info, warn, error, off (host default info, client default warn) |
--quiet / -q |
false | Silence logs (--log-level=off) |
--no-color |
false | Disable colour output (also NO_COLOR, HSSH_ASCII=1 for ASCII) |
--public / --tunnel |
local |
Public tunnel: local (default, no tunnel) | cloudflare | ngrok | localtunnel | bore | zrok |
--tunnel-token |
— | API token for providers that need one (flag > HSSH_TUNNEL_TOKEN env > file) |
--generate-token |
— | Print a strong token and exit (same as hssh token, ignores other flags) |
Running without auth prints a warning and asks for confirmation unless
--allow-unauthenticated is given. Precedence is flags > HSSH_PASSWORD /
HSSH_TOKEN env > JSON file > defaults, and the confirmation runs after the
file merge so a file cannot silently downgrade auth. hssh serve is an alias
for hssh host.
./bin/hssh connect http://127.0.0.1:8080
./bin/hssh connect=https://example.com:8443 --password 's3cret'
HSSH_PASSWORD='s3cret' ./bin/hssh connect https://example.com:8443
./bin/hssh connect https://example.com:8443 --session <id> # resume| Flag | Meaning |
|---|---|
--password / -k, --token / -t |
Credential (only sent over https/wss; never sent to an AuthNone host) |
--ca / -c |
PEM bundle to verify the server certificate (bad file is an error) |
--insecure / -n |
Skip verification (lab only) |
--disconnect-key |
Custom escape sequence (0x1d, C-], ~., 1b 5b); defaults Ctrl+] and ~. at line start |
--session |
Reattach to a live session (host needs --allow-resume; connect only, not sessions) |
--cwd |
Start in this server directory (default: host working dir; connect only) |
--timeout |
Connection timeout (default 15s; bare seconds accepted, e.g. 10) |
--term |
Override TERM sent to the server (default: $TERM or xterm-256color) |
--url / --server |
Alternate spellings for the target URL |
--no-status |
Skip the connection banner |
--log-level (default warn), --quiet / -q, --no-color |
Client log verbosity |
Target forms are all first-class: hssh connect http://host:8080,
hssh connect=http://host:8080, hssh connect --url http://host:8080.
A password is never taken from the URL. HSSH_PASSWORD / HSSH_TOKEN
avoid putting secrets in shell history. hssh help, hssh connect --help,
hssh host --help, and --version / -v are available.
Like SSH and VS Code's terminal, every session starts in a real directory and the server always knows it:
- Default is home. With no
--workdirand no--cwd, the shell starts in the server user's home directory, with a matching$PWD. - Host selects with
--workdir../bin/hssh host --workdir /srv/appmakes that the default for every session. - Client requests with
--cwd../bin/hssh connect=URL --cwd /srv/appstarts there. The path is on the server (relative paths resolve against the host working directory). A missing directory or a--cwdagainst a--per-session-cwdhost fails fast with an error — never a silent landing somewhere unexpected. hssh sessionsshows a CWD column with each session's directory.- Live tracking. Shells with shell-integration enabled (VS Code style)
report every
cdvia OSC 7 and the table follows in real time. Enable it:
# bash (~/.bashrc)
PROMPT_COMMAND='printf "\e]7;file://localhost%s\e\\" "$PWD";'"$PROMPT_COMMAND"
# zsh (~/.zshrc)
precmd() { printf '\e]7;file://localhost%s\e\\' "$PWD"; }
# fish (~/.config/fish/config.fish)
function __hssh_osc7 --on-variable PWD
printf '\e]7;file://localhost%s\e\\' "$PWD"
endShells without integration keep showing the creation-time directory.
./bin/hssh sessions http://127.0.0.1:8080
./bin/hssh sessions --url http://127.0.0.1:8080 --password 's3cret'Accepts the full connect auth/TLS flags (--password/--token/--ca/--insecure,
--url/--server, --timeout, --log-level/--quiet/--no-color) but not
--session/--cwd. With no URL it defaults to http://localhost:8080.
Shows id, client, shell, working directory, size, age, idle time and traffic. Never shows credentials or terminal content.
./bin/hssh token # strong random token for --token
./bin/hssh version # version, protocol, Go, PTY backend| Variable | Meaning |
|---|---|
HSSH_PASSWORD / HSSH_TOKEN |
Credential for host or client (flag wins over env over file) |
HSSH_SHELL |
Override shell auto-detection |
HSSH_CONFIG |
Path to a JSON host config file (else ./hssh.json, else ~/.hssh/host.json, else ~/.hssh/config.json, else ~/.config/hssh/host.json) |
HSSH_DIR |
Override the single HSSH home (default ~/.hssh; test-only) |
HSSH_LOG_FORMAT=json |
Structured logs (json vs text) |
NO_COLOR / --no-color |
Disable colour output |
HSSH_ASCII=1 |
Force ASCII glyphs (no box-drawing/Unicode) |
TERM (--term overrides) |
Terminal type sent as terminal_start.term (validated to [A-Za-z0-9-_.], max 64) |
COLORFGBG |
Dark/light hint sent as color_scheme |
SHELL, HOME (USERPROFILE on Windows) |
Shell/home fallback for --shell/--workdir |
HSSH_SESSION, HSSH_CLIENT |
Set by the server inside each remote shell (id, colour-scheme hint) |
HSSH_PUBLIC / HSSH_TUNNEL |
Public tunnel provider (same as --public; default local) |
HSSH_TUNNEL_TOKEN |
API token for tunnel providers (flag wins over env over file) |
NGROK_AUTHTOKEN / HSSH_NGROK_TOKEN |
ngrok token (needs one; HSSH_TUNNEL_TOKEN wins) |
CLOUDFLARE_API_TOKEN / TUNNEL_TOKEN |
cloudflare token (optional for quick tunnels) |
ZROK_TOKEN / HSSH_ZROK_TOKEN |
zrok token (only for private shares) |
JSON keys: host, port, shell, workdir, password, token, auth,
tls_cert, tls_key, max_sessions, per_session_cwd, allow_resume,
allow_unauthenticated, output_buffer (4M), max_frame_size,
log_level, idle_timeout, session_timeout, heartbeat (durations like
30s/5m or bare seconds), public (local default; tunnel is an alias),
tunnel_token (public_token is an alias). Unknown keys are an error.
HSSH_TEST_BINARY is test-only.
Local is always the default, even when token env vars are set:
./bin/hssh host --port 8080 --allow-unauthenticated
./bin/hssh host --port 8080 --public cloudflare --allow-unauthenticated
./bin/hssh host --port 8080 --public ngrok --tunnel-token "$NGROK_AUTHTOKEN"
HSSH_PUBLIC=localtunnel ./bin/hssh host --port 8080
HSSH_PUBLIC=bore ./bin/hssh host --port 8080
HSSH_PUBLIC=zrok ./bin/hssh host --port 8080| Provider | Binary | API token |
|---|---|---|
local (default) |
none | none |
cloudflare (cf) |
cloudflared |
optional quick tunnel; HSSH_TUNNEL_TOKEN / CLOUDFLARE_API_TOKEN |
ngrok |
ngrok |
required: --tunnel-token or HSSH_TUNNEL_TOKEN or NGROK_AUTHTOKEN |
localtunnel (lt) |
npx (localtunnel) |
none |
bore |
bore |
none (public endpoint http://bore.pub:<port>) |
zrok |
zrok |
only for private shares |
The host prints the public URL and a ready hssh connect=<public-url> line;
Ctrl+C stops both tunnel and host. Missing binaries fail fast with install
hints. Tokens are never logged.
HSSH never scatters files across $HOME or /tmp. Everything it stores
lives under one directory ($HSSH_DIR overrides it in tests):
~/.hssh/
host.json optional config (also config.json; see HSSH_CONFIG above)
sessions/<id>/ per-session working dirs (--per-session-cwd only)
history/<id> per-session shell history (HISTFILE, 0600)
logs/ reserved (logs currently go to stderr)
In particular:
~/gois the Go toolchain'sGOPATHmodule cache, not HSSH.~/hsshis your project checkout itself, not created by the binary.~/hsshbin,~/hsshrunwere your manual test dirs (binaries + logs), never created by HSSH code.- Without
--per-session-cwdHSSH creates no session dirs at all; with it the root is~/.hssh/sessions, never/tmp/hssh-sessions-*. - Each remote shell gets
HISTFILE=~/.hssh/history/<session-id>so concurrent sessions never share~/.bash_history.
- Credentials travel only over TLS. Both client and server refuse
password/token over plain
http/ws. - No-auth mode requires explicit confirmation (
--allow-unauthenticated). - A session id alone grants nothing: resume additionally requires the same
client address and a fresh successful authentication, plus the host
opt-in
--allow-resume. - Logs redact secrets by key name and by
password=/token=value patterns. - HTTP surface sends no CORS headers and validates
OriginagainstHost(DNS-rebinding hardening). There is no browser client in v1 by design.
- Discovery:
GET /health(aliasGET /version) →{name, version, protocol, websocket, tls, auth, uptime_seconds, endpoints};GET /is a human index withusage. Endpoints are/healthand/connect, subprotocolhssh.v1(internal/wsx). - Terminal:
GET /connectupgraded to WebSocket, subprotocolhssh.v1(mismatched subprotocol is rejected). No CORS headers are sent. - Every WebSocket frame is
[1 byte opcode][payload]. Opcodes:0x01 input (c→s),0x02 output (s→c),0x10-0x1Acontrol,0x20-0x21ping/pong,0x30-0x31sessions. Control frames are capped at1 MiB(MaxControlFrame). Binary frames carry raw terminal bytes (no JSON/base64); text frames carry JSON control messages (hello,auth,auth_result,terminal_start,terminal_input,inputpayload,outputpayload,resize,signal(INT|TERM|HUP|QUIT|KILL|WINCH|TSTP|CONT),exit,session_info,disconnect,error(protocol_mismatch/auth_failed/session_limit/ pty_failed/shell_failed/resize_failed/internal_error/bad_message),ping/pong(seq,ts),sessions_request,sessions_list). - Handshake order per connection:
hello→auth_result(challenge) →auth(with optionalresumesession id) →auth_result(ok) →terminal_start(cols,rows,term,cwd,color_scheme) orsessions_request. The handshake has a20sdeadline coveringterminal_start.
cmd/hssh/main.go entrypoint
internal/
protocol/ opcodes, codec, message types (protocol.go, messages.go)
pty/ real PTY: pty.go/handle.go, pty_unix.go/pty_windows.go/pty_unsupported.go, signal_*.go
shell/ shell resolution + filtered environment (shell.go/shell_unix.go/shell_windows.go, allowlist)
terminal/ server PTY session + pumps (session.go), escape parser (escape.go), OSC 7 live CWD (osc7.go), raw mode (terminal.go)
auth/ password/token verifier, stateless, constant-time (auth.go)
config/ flags + JSON file + validation (config.go/file.go)
wsx/ hardened WebSocket wrapper: limits/origin/TLS/subprotocol (wsx.go/url.go: /connect, /health)
httpapi/ /health (+/version alias), /, /connect
server/ host, handshake, session lifecycle, resume (host.go/session.go/resume.go/sink.go)
client/ dial, auth, raw-mode loops, sessions query (client.go/http.go: /health discovery, TERM/COLORFGBG)
cli/ commands, flag parser, prompts, output (app.go/flags.go/prompt.go/support.go; `serve` alias)
ui/ terminal printer (ui.go; NO_COLOR/HSSH_ASCII aware)
sessions/ session registry (manager.go)
logging/ redacting structured logger
tests/ e2e: real binaries driven through a real PTY (e2e_test.go/harness_test.go/helpers_test.go)
hssh version prints version, protocol, WebSocket subprotocol, Go, and the PTY
backend (native POSIX / ConPTY).
go build ./...
GOOS=windows go build ./...
go vet ./...
go test ./... # everything
go test ./internal/... # unit + protocol/auth/TLS tests
go test ./tests/ -count=1 # e2e: real binaries, real PTY (needs bin/hssh)
go test ./internal/server/ -count=1 -run TestResume -vRequires Go 1.27.1+ (go.mod).
Notes:
- Don't pipe
go testintohead—SIGPIPEkills the test binary and the output misleads. Redirect to a file andtailit instead. go test -raceis not supported onandroid/arm64(Termux).- Never
pkill -f <pattern>from the same shell — the pattern matches your own command line. Kill by exact PID instead.
- Windows uses ConPTY: it cross-compiles, but on-device testing is not done here.
- No browser client, no file transfer, no port forwarding in v1 (by design).
MIT — see LICENSE.