Skip to content

Commit 7ed763a

Browse files
feat: allocate real pty (#8)
* feat: allocate real pty This adds a new PtyHandler to handle allocating PTYs and storing them in a platform specific field in `Pty`. This PR is backward-compatible, it defaults to EmulatePty handler that sets the `emulatePty` field in context and uses `PtyWriter` to preserve the current behavor. * fix: update pty godoc * fix: convert pty handlers to server options * fix: consume resize events on pty * feat: support windows conpty * feat: add pty start process example * fix: return tty name * fix: ptystart example for unix * fix: imports Signed-off-by: Carlos Alexandro Becker <[email protected]> * fix: update Signed-off-by: Carlos Alexandro Becker <[email protected]> * chore: deps --------- Signed-off-by: Carlos Alexandro Becker <[email protected]> Co-authored-by: Carlos Alexandro Becker <[email protected]>
1 parent 7e1d867 commit 7ed763a

File tree

12 files changed

+876
-13
lines changed

12 files changed

+876
-13
lines changed

_examples/ssh-ptystart/main.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"log"
7+
"os"
8+
"os/exec"
9+
"runtime"
10+
"time"
11+
12+
"github.com/charmbracelet/ssh"
13+
)
14+
15+
func main() {
16+
ssh.Handle(func(s ssh.Session) {
17+
log.Printf("connected %s %s %q", s.User(), s.RemoteAddr(), s.RawCommand())
18+
defer log.Printf("disconnected %s %s", s.User(), s.RemoteAddr())
19+
20+
pty, _, ok := s.Pty()
21+
if !ok {
22+
io.WriteString(s, "No PTY requested.\n")
23+
s.Exit(1)
24+
return
25+
}
26+
27+
name := "bash"
28+
if runtime.GOOS == "windows" {
29+
name = "powershell.exe"
30+
}
31+
cmd := exec.Command(name)
32+
cmd.Env = append(os.Environ(), "SSH_TTY="+pty.Name(), fmt.Sprintf("TERM=%s", pty.Term))
33+
if err := pty.Start(cmd); err != nil {
34+
fmt.Fprintln(s, err.Error())
35+
s.Exit(1)
36+
return
37+
}
38+
39+
if runtime.GOOS == "windows" {
40+
// ProcessState gets populated by pty.Start waiting on the process
41+
// to exit.
42+
for cmd.ProcessState == nil {
43+
time.Sleep(100 * time.Millisecond)
44+
}
45+
46+
s.Exit(cmd.ProcessState.ExitCode())
47+
} else {
48+
if err := cmd.Wait(); err != nil {
49+
fmt.Fprintln(s, err)
50+
s.Exit(cmd.ProcessState.ExitCode())
51+
}
52+
}
53+
})
54+
55+
log.Println("starting ssh server on port 2222...")
56+
if err := ssh.ListenAndServe(":2222", nil, ssh.AllocatePty()); err != nil && err != ssh.ErrServerClosed {
57+
log.Fatal(err)
58+
}
59+
}

context.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ var (
5555
// ContextKeyPublicKey is a context key for use with Contexts in this package.
5656
// The associated value will be of type PublicKey.
5757
ContextKeyPublicKey = &contextKey{"public-key"}
58+
59+
// ContextKeySession is a context key for use with Contexts in this package.
60+
// The associated value will be of type Session.
61+
ContextKeySession = &contextKey{"session"}
5862
)
5963

6064
// Context is a package specific context interface. It exposes connection

go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ go 1.17
44

55
require (
66
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
7+
github.com/charmbracelet/x/exp/term v0.0.0-20240117030132-5a84c80527c7
8+
github.com/creack/pty v1.1.21
9+
github.com/u-root/u-root v0.11.0
710
golang.org/x/crypto v0.17.0
11+
golang.org/x/sys v0.16.0
812
)
913

10-
require golang.org/x/sys v0.15.0 // indirect
14+
require github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 // indirect

go.sum

Lines changed: 341 additions & 1 deletion
Large diffs are not rendered by default.

options.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func HostKeyPEM(bytes []byte) Option {
6868
// denying PTY requests.
6969
func NoPty() Option {
7070
return func(srv *Server) error {
71-
srv.PtyCallback = func(ctx Context, pty Pty) bool {
71+
srv.PtyCallback = func(Context, Pty) bool {
7272
return false
7373
}
7474
return nil
@@ -82,3 +82,31 @@ func WrapConn(fn ConnCallback) Option {
8282
return nil
8383
}
8484
}
85+
86+
var contextKeyEmulatePty = &contextKey{"emulate-pty"}
87+
88+
func emulatePtyHandler(ctx Context, _ Session, _ Pty) (func() error, error) {
89+
ctx.SetValue(contextKeyEmulatePty, true)
90+
return func() error { return nil }, nil
91+
}
92+
93+
// EmulatePty returns a functional option that fakes a PTY. It uses PtyWriter
94+
// underneath.
95+
func EmulatePty() Option {
96+
return func(s *Server) error {
97+
s.PtyHandler = emulatePtyHandler
98+
return nil
99+
}
100+
}
101+
102+
// AllocatePty returns a functional option that allocates a PTY. Implementers
103+
// who wish to use an actual PTY should use this along with the platform
104+
// specific PTY implementation defined in pty_*.go.
105+
func AllocatePty() Option {
106+
return func(s *Server) error {
107+
s.PtyHandler = func(_ Context, s Session, pty Pty) (func() error, error) {
108+
return s.(*session).ptyAllocate(pty.Term, pty.Window, pty.Modes)
109+
}
110+
return nil
111+
}
112+
}

pty.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ package ssh
22

33
import (
44
"bytes"
5+
"errors"
56
"io"
7+
"os/exec"
68
)
79

10+
// ErrUnsupported is returned when the platform does not support PTY.
11+
var ErrUnsupported = errors.New("pty unsupported")
12+
813
// NewPtyWriter creates a writer that handles when the session has a active
914
// PTY, replacing the \n with \r\n.
1015
func NewPtyWriter(w io.Writer) io.Writer {
@@ -55,3 +60,12 @@ func (rw readWriterDelegate) Read(p []byte) (n int, err error) {
5560
func (rw readWriterDelegate) Write(p []byte) (n int, err error) {
5661
return rw.w.Write(p)
5762
}
63+
64+
// Start starts a *exec.Cmd attached to the Session. If a PTY is allocated,
65+
// it will use that for I/O.
66+
// On Windows, the process execution lifecycle is not managed by Go and has to
67+
// be managed manually. This means that c.Wait() won't work.
68+
// See https://github.com/charmbracelet/x/blob/main/exp/term/windows/conpty/conpty_windows.go
69+
func (p *Pty) Start(c *exec.Cmd) error {
70+
return p.start(c)
71+
}

pty_other.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//go:build !linux && !darwin && !freebsd && !dragonfly && !netbsd && !openbsd && !solaris && !windows
2+
// +build !linux,!darwin,!freebsd,!dragonfly,!netbsd,!openbsd,!solaris,!windows
3+
4+
package ssh
5+
6+
import (
7+
"os/exec"
8+
9+
"golang.org/x/crypto/ssh"
10+
)
11+
12+
type impl struct{}
13+
14+
func (i *impl) IsZero() bool {
15+
return true
16+
}
17+
18+
func (i *impl) Name() string {
19+
return ""
20+
}
21+
22+
func (i *impl) Read(p []byte) (n int, err error) {
23+
return 0, ErrUnsupported
24+
}
25+
26+
func (i *impl) Write(p []byte) (n int, err error) {
27+
return 0, ErrUnsupported
28+
}
29+
30+
func (i *impl) Resize(w int, h int) error {
31+
return ErrUnsupported
32+
}
33+
34+
func (i *impl) Close() error {
35+
return nil
36+
}
37+
38+
func (*impl) start(*exec.Cmd) error {
39+
return ErrUnsupported
40+
}
41+
42+
func newPty(Context, string, Window, ssh.TerminalModes) (impl, error) {
43+
return impl{}, ErrUnsupported
44+
}

pty_unix.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
2+
// +build darwin dragonfly freebsd linux netbsd openbsd solaris
3+
4+
package ssh
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"syscall"
11+
12+
"github.com/creack/pty"
13+
"github.com/u-root/u-root/pkg/termios"
14+
"golang.org/x/crypto/ssh"
15+
"golang.org/x/sys/unix"
16+
)
17+
18+
type impl struct {
19+
// Master is the master PTY file descriptor.
20+
Master *os.File
21+
22+
// Slave is the slave PTY file descriptor.
23+
Slave *os.File
24+
}
25+
26+
func (i *impl) IsZero() bool {
27+
return i.Master == nil && i.Slave == nil
28+
}
29+
30+
// Name returns the name of the slave PTY.
31+
func (i *impl) Name() string {
32+
return i.Slave.Name()
33+
}
34+
35+
// Read implements ptyInterface.
36+
func (i *impl) Read(p []byte) (n int, err error) {
37+
return i.Master.Read(p)
38+
}
39+
40+
// Write implements ptyInterface.
41+
func (i *impl) Write(p []byte) (n int, err error) {
42+
return i.Master.Write(p)
43+
}
44+
45+
func (i *impl) Close() error {
46+
if err := i.Master.Close(); err != nil {
47+
return err
48+
}
49+
return i.Slave.Close()
50+
}
51+
52+
func (i *impl) Resize(w int, h int) (rErr error) {
53+
conn, err := i.Master.SyscallConn()
54+
if err != nil {
55+
return err
56+
}
57+
58+
return conn.Control(func(fd uintptr) {
59+
rErr = termios.SetWinSize(fd, &termios.Winsize{
60+
Winsize: unix.Winsize{
61+
Row: uint16(h),
62+
Col: uint16(w),
63+
},
64+
})
65+
})
66+
}
67+
68+
func (i *impl) start(c *exec.Cmd) error {
69+
c.Stdin, c.Stdout, c.Stderr = i.Slave, i.Slave, i.Slave
70+
if c.SysProcAttr == nil {
71+
c.SysProcAttr = &syscall.SysProcAttr{}
72+
}
73+
c.SysProcAttr.Setctty = true
74+
c.SysProcAttr.Setsid = true
75+
return c.Start()
76+
}
77+
78+
func newPty(_ Context, _ string, win Window, modes ssh.TerminalModes) (_ impl, rErr error) {
79+
ptm, pts, err := pty.Open()
80+
if err != nil {
81+
return impl{}, err
82+
}
83+
84+
conn, err := ptm.SyscallConn()
85+
if err != nil {
86+
return impl{}, err
87+
}
88+
89+
if err := conn.Control(func(fd uintptr) {
90+
rErr = applyTerminalModesToFd(fd, win.Width, win.Height, modes)
91+
}); err != nil {
92+
return impl{}, err
93+
}
94+
95+
return impl{Master: ptm, Slave: pts}, rErr
96+
}
97+
98+
func applyTerminalModesToFd(fd uintptr, width int, height int, modes ssh.TerminalModes) error {
99+
// Get the current TTY configuration.
100+
tios, err := termios.GTTY(int(fd))
101+
if err != nil {
102+
return fmt.Errorf("GTTY: %w", err)
103+
}
104+
105+
// Apply the modes from the SSH request.
106+
tios.Row = height
107+
tios.Col = width
108+
109+
for c, v := range modes {
110+
if c == ssh.TTY_OP_ISPEED {
111+
tios.Ispeed = int(v)
112+
continue
113+
}
114+
if c == ssh.TTY_OP_OSPEED {
115+
tios.Ospeed = int(v)
116+
continue
117+
}
118+
k, ok := terminalModeFlagNames[c]
119+
if !ok {
120+
continue
121+
}
122+
if _, ok := tios.CC[k]; ok {
123+
tios.CC[k] = uint8(v)
124+
continue
125+
}
126+
if _, ok := tios.Opts[k]; ok {
127+
tios.Opts[k] = v > 0
128+
continue
129+
}
130+
}
131+
132+
// Save the new TTY configuration.
133+
if _, err := tios.STTY(int(fd)); err != nil {
134+
return fmt.Errorf("STTY: %w", err)
135+
}
136+
137+
return nil
138+
}
139+
140+
// terminalModeFlagNames maps the SSH terminal mode flags to mnemonic
141+
// names used by the termios package.
142+
var terminalModeFlagNames = map[uint8]string{
143+
ssh.VINTR: "intr",
144+
ssh.VQUIT: "quit",
145+
ssh.VERASE: "erase",
146+
ssh.VKILL: "kill",
147+
ssh.VEOF: "eof",
148+
ssh.VEOL: "eol",
149+
ssh.VEOL2: "eol2",
150+
ssh.VSTART: "start",
151+
ssh.VSTOP: "stop",
152+
ssh.VSUSP: "susp",
153+
ssh.VDSUSP: "dsusp",
154+
ssh.VREPRINT: "rprnt",
155+
ssh.VWERASE: "werase",
156+
ssh.VLNEXT: "lnext",
157+
ssh.VFLUSH: "flush",
158+
ssh.VSWTCH: "swtch",
159+
ssh.VSTATUS: "status",
160+
ssh.VDISCARD: "discard",
161+
ssh.IGNPAR: "ignpar",
162+
ssh.PARMRK: "parmrk",
163+
ssh.INPCK: "inpck",
164+
ssh.ISTRIP: "istrip",
165+
ssh.INLCR: "inlcr",
166+
ssh.IGNCR: "igncr",
167+
ssh.ICRNL: "icrnl",
168+
ssh.IUCLC: "iuclc",
169+
ssh.IXON: "ixon",
170+
ssh.IXANY: "ixany",
171+
ssh.IXOFF: "ixoff",
172+
ssh.IMAXBEL: "imaxbel",
173+
ssh.IUTF8: "iutf8",
174+
ssh.ISIG: "isig",
175+
ssh.ICANON: "icanon",
176+
ssh.XCASE: "xcase",
177+
ssh.ECHO: "echo",
178+
ssh.ECHOE: "echoe",
179+
ssh.ECHOK: "echok",
180+
ssh.ECHONL: "echonl",
181+
ssh.NOFLSH: "noflsh",
182+
ssh.TOSTOP: "tostop",
183+
ssh.IEXTEN: "iexten",
184+
ssh.ECHOCTL: "echoctl",
185+
ssh.ECHOKE: "echoke",
186+
ssh.PENDIN: "pendin",
187+
ssh.OPOST: "opost",
188+
ssh.OLCUC: "olcuc",
189+
ssh.ONLCR: "onlcr",
190+
ssh.OCRNL: "ocrnl",
191+
ssh.ONOCR: "onocr",
192+
ssh.ONLRET: "onlret",
193+
ssh.CS7: "cs7",
194+
ssh.CS8: "cs8",
195+
ssh.PARENB: "parenb",
196+
ssh.PARODD: "parodd",
197+
ssh.TTY_OP_ISPEED: "tty_op_ispeed",
198+
ssh.TTY_OP_OSPEED: "tty_op_ospeed",
199+
}

0 commit comments

Comments
 (0)