Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # one OS failing must not hide the others' results
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
Expand All @@ -16,6 +17,10 @@ jobs:
go-version: "1.26"
- run: go vet ./...
- run: go test ./...
# Show which service manager each OS really used (launchd on macOS,
# breakaway/WMI on Windows), not just that the suite passed.
- name: Service manager and Windows console, verbose
run: go test -count=1 -v -run "TestSessionRunsUnderTheRealServiceManager|TestWindowsSessionOnConPTY|TestDaemonStartsWithoutTheForkOverride|TestWMIStartsAProcess|TestChooseSupervisor" ./internal/server
- if: runner.os == 'Linux'
run: go test -race ./...
- run: go build ./cmd/hqsh
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,14 @@ running. The daemon is started by the OS service manager where there is one:
| --- | --- | --- |
| Linux with systemd | a user unit, `hqsh-<session>.service` | yes, with lingering on |
| macOS | a launchd job, `sh.hqterm.hqsh.<session>`, in `user/<uid>` | yes |
| Windows 10 1809+ / Server 2019+ | a process outside the ssh connection's job (breakaway, else WMI `Win32_Process.Create`) on a ConPTY | yes |
| anything else | a detached process (setsid) | unless the OS kills it |

On Windows the session's shell is PowerShell 7 (`pwsh`), else Windows
PowerShell, else `cmd.exe`; `HQSH_SHELL` picks another on any OS. The host
needs the OpenSSH server (Settings → Optional features), and hqsh.exe on the
PATH or in `~\.local\bin`.

`hqsh server setup` shows which applies and turns lingering on (`loginctl
enable-linger`) where systemd needs it; `--check` only reports. Without
lingering, logind stops a user's units at their last logout, so hqsh falls
Expand Down Expand Up @@ -105,8 +111,8 @@ tested end to end (a real shell over a pipe in CI, and over ssh by hand):
reconnect after the connection dies, replay of exactly the missed output,
detach and re-attach, exit status, Kitty and iTerm2 image escapes passed
through untouched. 0.2.0 adds shared attach (several clients on one
session), `--steal` and `--read-only`. The server side runs on Linux and macOS; the client also
builds for Windows. Not yet: local echo prediction, a WebSocket bridge so a
session), `--steal` and `--read-only`. The server side runs on Linux (systemd), macOS (launchd) and Windows (ConPTY); the client
runs on all three. Not yet: local echo prediction, a WebSocket bridge so a
phone/PWA can attach.

Works with any modern terminal: Kitty, Ghostty, WezTerm, Rio, iTerm2,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ require (
golang.org/x/term v0.46.0
)

require golang.org/x/sys v0.48.0 // indirect
require golang.org/x/sys v0.48.0
20 changes: 20 additions & 0 deletions internal/server/console.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package server

import "io"

// console is the shell's terminal as the daemon sees it: a PTY on Unix, a
// pseudo console (ConPTY) on Windows. Reads are the shell's output, writes
// are keystrokes.
type console interface {
io.ReadWriteCloser
Resize(cols, rows uint16) error
}

// The platform files provide:
//
// startShell(session, term string, cols, rows uint16) (console, func() int, error)
// starts the user's shell on a new console; the func waits for it to
// exit and returns its status (128+signal for a signal on Unix).
// lockSession(f *os.File) error exclusive, non-blocking: one daemon per session
// chmodSocket(path string) error the socket is the user's alone
// isDead(err error) bool a dial error meaning no daemon is behind the socket
113 changes: 113 additions & 0 deletions internal/server/console_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//go:build !windows

package server

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"

"github.com/creack/pty"
)

type unixConsole struct{ *os.File }

func (c unixConsole) Resize(cols, rows uint16) error {
return pty.Setsize(c.File, &pty.Winsize{Cols: cols, Rows: rows})
}

// startShell runs the user's login shell ($HQSH_SHELL, else $SHELL, else
// /bin/sh) in their home directory on a new PTY.
func startShell(session, term string, cols, rows uint16) (console, func() int, error) {
shell := os.Getenv("HQSH_SHELL")
if shell == "" {
shell = os.Getenv("SHELL")
}
if shell == "" {
shell = "/bin/sh"
}
cmd := exec.Command(shell, "-l")
if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home
}
cmd.Env = shellEnv(os.Environ(), usableTerm(term), session)
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows})
if err != nil {
return nil, nil, err
}
return unixConsole{ptmx}, func() int {
_ = cmd.Wait()
return exitCode(cmd.ProcessState)
}, nil
}

func lockSession(f *os.File) error {
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
}

func chmodSocket(path string) error { return os.Chmod(path, 0o600) }

func isDead(err error) bool {
return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
}

func exitCode(ps *os.ProcessState) int {
if ps == nil {
return 1
}
if ws, ok := ps.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
return 128 + int(ws.Signal())
}
return ps.ExitCode()
}

func shellEnv(env []string, term, session string) []string {
out := make([]string, 0, len(env)+2)
for _, kv := range env {
if strings.HasPrefix(kv, "TERM=") || strings.HasPrefix(kv, "HQSH_SESSION=") {
continue
}
out = append(out, kv)
}
return append(out, "TERM="+term, "HQSH_SESSION="+session)
}

// usableTerm keeps the client's TERM when this host has a terminfo entry for
// it (xterm-kitty often is missing), else falls back to xterm-256color.
func usableTerm(term string) string {
const fallback = "xterm-256color"
if term == "" || strings.ContainsAny(term, "/\\\x00") || strings.HasPrefix(term, ".") {
return fallback
}
var dirs []string
if t := os.Getenv("TERMINFO"); t != "" {
dirs = append(dirs, t)
}
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".terminfo"))
}
if td := os.Getenv("TERMINFO_DIRS"); td != "" {
dirs = append(dirs, filepath.SplitList(td)...)
}
dirs = append(dirs, "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo", "/usr/share/lib/terminfo", "/opt/homebrew/share/terminfo", "/usr/local/share/terminfo")
anyDir := false
for _, dir := range dirs {
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
continue
}
anyDir = true
for _, sub := range []string{term[:1], fmt.Sprintf("%x", term[0])} {
if _, err := os.Stat(filepath.Join(dir, sub, term)); err == nil {
return term
}
}
}
if !anyDir {
return term // no terminfo database to check against; trust the client
}
return fallback
}
170 changes: 170 additions & 0 deletions internal/server/console_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//go:build windows

package server

import (
"errors"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"unicode/utf16"
"unsafe"

"golang.org/x/sys/windows"
)

// conPTY is a Windows pseudo console (Windows 10 1809 and later): the shell
// writes VT sequences to it like to a Unix PTY, and keystrokes go in as VT.
type conPTY struct {
hpc windows.Handle
in *os.File // our end of the shell's input
out *os.File // our end of the shell's output
once sync.Once
}

func (c *conPTY) Read(p []byte) (int, error) { return c.out.Read(p) }
func (c *conPTY) Write(p []byte) (int, error) { return c.in.Write(p) }

func (c *conPTY) Resize(cols, rows uint16) error {
return windows.ResizePseudoConsole(c.hpc, windows.Coord{X: int16(cols), Y: int16(rows)})
}

// Close ends the pseudo console (which ends the shell, if it is still
// there) and our pipe ends. Closing the console is also what lets the last
// read return EOF.
func (c *conPTY) Close() error {
c.once.Do(func() {
windows.ClosePseudoConsole(c.hpc)
c.in.Close()
c.out.Close()
})
return nil
}

// windowsShell picks the shell: $HQSH_SHELL, else PowerShell 7 (pwsh),
// else Windows PowerShell, else %ComSpec% (cmd.exe).
func windowsShell() string {
if s := os.Getenv("HQSH_SHELL"); s != "" {
return s
}
for _, s := range []string{"pwsh.exe", "powershell.exe"} {
if p, err := exec.LookPath(s); err == nil {
return p
}
}
if s := os.Getenv("ComSpec"); s != "" {
return s
}
return `C:\Windows\System32\cmd.exe`
}

func startShell(session, term string, cols, rows uint16) (console, func() int, error) {
var inR, inW, outR, outW windows.Handle
if err := windows.CreatePipe(&inR, &inW, nil, 0); err != nil {
return nil, nil, err
}
if err := windows.CreatePipe(&outR, &outW, nil, 0); err != nil {
windows.CloseHandle(inR)
windows.CloseHandle(inW)
return nil, nil, err
}
var hpc windows.Handle
if err := windows.CreatePseudoConsole(windows.Coord{X: int16(cols), Y: int16(rows)}, inR, outW, 0, &hpc); err != nil {
for _, h := range []windows.Handle{inR, inW, outR, outW} {
windows.CloseHandle(h)
}
return nil, nil, err
}
// The pseudo console holds its own copies of these.
windows.CloseHandle(inR)
windows.CloseHandle(outW)
con := &conPTY{hpc: hpc, in: os.NewFile(uintptr(inW), "conpty-in"), out: os.NewFile(uintptr(outR), "conpty-out")}

attrs, err := windows.NewProcThreadAttributeList(1)
if err != nil {
con.Close()
return nil, nil, err
}
defer attrs.Delete()
// The attribute's value is the HPCON itself, not a pointer to it.
if err := attrs.Update(windows.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, *(*unsafe.Pointer)(unsafe.Pointer(&hpc)), unsafe.Sizeof(hpc)); err != nil {
con.Close()
return nil, nil, err
}
si := windows.StartupInfoEx{ProcThreadAttributeList: attrs.List()}
si.Cb = uint32(unsafe.Sizeof(si))
// No inherited std handles: the console is the shell's only terminal.
si.Flags = windows.STARTF_USESTDHANDLES

cmdline, err := windows.UTF16PtrFromString(windows.EscapeArg(windowsShell()))
if err != nil {
con.Close()
return nil, nil, err
}
var dir *uint16
if home, err := os.UserHomeDir(); err == nil {
dir, _ = windows.UTF16PtrFromString(home)
}
env := envBlock(shellEnvWindows(os.Environ(), session))
var pi windows.ProcessInformation
if err := windows.CreateProcess(nil, cmdline, nil, nil, false,
windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT,
&env[0], dir, &si.StartupInfo, &pi); err != nil {
con.Close()
return nil, nil, err
}
windows.CloseHandle(pi.Thread)
return con, func() int {
defer windows.CloseHandle(pi.Process)
if _, err := windows.WaitForSingleObject(pi.Process, windows.INFINITE); err != nil {
return 1
}
var code uint32
if err := windows.GetExitCodeProcess(pi.Process, &code); err != nil {
return 1
}
return int(code)
}, nil
}

// shellEnvWindows marks the session; TERM means nothing to Windows programs.
func shellEnvWindows(env []string, session string) []string {
out := make([]string, 0, len(env)+1)
for _, kv := range env {
if !strings.HasPrefix(strings.ToUpper(kv), "HQSH_SESSION=") {
out = append(out, kv)
}
}
return append(out, "HQSH_SESSION="+session)
}

// envBlock is CreateProcess's environment: NUL-separated UTF-16, NUL-NUL ended.
func envBlock(env []string) []uint16 {
var b []uint16
for _, kv := range env {
if strings.ContainsRune(kv, 0) {
continue
}
b = append(b, utf16.Encode([]rune(kv))...)
b = append(b, 0)
}
if len(b) == 0 {
b = append(b, 0)
}
return append(b, 0)
}

func lockSession(f *os.File) error {
return windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{})
}

// chmodSocket: Windows has no mode bits for it; the socket lives in the
// user's profile, which only they (and admins) can open.
func chmodSocket(path string) error { return nil }

func isDead(err error) bool {
return errors.Is(err, windows.WSAECONNREFUSED) || errors.Is(err, syscall.ENOENT) ||
errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, os.ErrNotExist)
}
Loading
Loading