Commit d642d4b

Antonio Mika  ·  2026-10-22 10:46:32 -0400 EDT
parent 37379a4
fix(pssh): apply pty-req before running the session's command
6 files changed,  +109, -52
+1, -2
......@@ -5,7 +5,6 @@ import (
55 "os"
66 "os/signal"
77 "syscall"
8- "time"
98
109 "github.com/picosh/pico/pkg/db/postgres"
1110 "github.com/picosh/pico/pkg/pssh"
......@@ -53,7 +52,7 @@ func StartSshServer() {
5352 scp.Middleware(handler),
5453 rsync.Middleware(handler),
5554 auth.Middleware(handler),
56- pssh.PtyMdw(pssh.DeprecatedNotice(), 200*time.Millisecond),
55+ pssh.PtyMdw(pssh.DeprecatedNotice()),
5756 pssh.LogMiddleware(handler, dbh),
5857 },
5958 []pssh.SSHServerMiddleware{
+1, -2
......@@ -5,7 +5,6 @@ import (
55 "os"
66 "os/signal"
77 "syscall"
8- "time"
98
109 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
1110 "github.com/picosh/pico/pkg/db/postgres"
......@@ -106,7 +105,7 @@ func StartSshServer() {
106105 PgsDB: pgsDB,
107106 Logger: pssh.GetLogger(sesh),
108107 }
109- return pssh.PtyMdw(createTui(shrd), 200*time.Millisecond)(next)(sesh)
108+ return pssh.PtyMdw(createTui(shrd))(next)(sesh)
110109 }
111110 },
112111 Middleware(cliHandler),
+1, -2
......@@ -5,7 +5,6 @@ import (
55 "os"
66 "os/signal"
77 "syscall"
8- "time"
98
109 "github.com/picosh/pico/pkg/db/postgres"
1110 "github.com/picosh/pico/pkg/pssh"
......@@ -63,7 +62,7 @@ func StartSshServer() {
6362 scp.Middleware(handler),
6463 rsync.Middleware(handler),
6564 auth.Middleware(handler),
66- pssh.PtyMdw(pssh.DeprecatedNotice(), 200*time.Millisecond),
65+ pssh.PtyMdw(pssh.DeprecatedNotice()),
6766 pssh.LogMiddleware(handler, dbh),
6867 },
6968 []pssh.SSHServerMiddleware{
+1, -15
......@@ -3,7 +3,6 @@ package pssh
33 import (
44 "encoding/binary"
55 "fmt"
6- "time"
76 )
87
98 func SessionMessage(sesh *SSHServerConnSession, msg string) {
......@@ -24,22 +23,9 @@ func DeprecatedNotice() SSHServerMiddleware {
2423 }
2524 }
2625
27-func PtyMdw(mdw SSHServerMiddleware, waitTimeout time.Duration) SSHServerMiddleware {
26+func PtyMdw(mdw SSHServerMiddleware) SSHServerMiddleware {
2827 return func(next SSHServerHandler) SSHServerHandler {
2928 return func(sesh *SSHServerConnSession) error {
30- if waitTimeout > 0 {
31- afterTime := time.Now().Add(waitTimeout)
32-
33- for time.Now().After(afterTime) {
34- _, _, ok := sesh.Pty()
35- if ok {
36- break
37- }
38-
39- time.Sleep(50 * time.Millisecond)
40- }
41- }
42-
4329 _, _, ok := sesh.Pty()
4430 if !ok {
4531 return next(sesh)
+26, -31
......@@ -186,6 +186,24 @@ func (s *SSHServerConnSession) Pty() (*Pty, <-chan Window, bool) {
186186 return s.pty, s.winch, true
187187 }
188188
189+func (s *SSHServerConnSession) handlePtyReq(req *ssh.Request) {
190+ ptyReq, ok := parsePtyRequest(req.Payload)
191+
192+ s.mu.Lock()
193+ if ok && s.pty == nil {
194+ s.pty = &ptyReq
195+ s.winch = make(chan Window, 1)
196+ s.winch <- ptyReq.Window
197+ } else {
198+ ok = false
199+ }
200+ s.mu.Unlock()
201+
202+ if err := req.Reply(ok, nil); err != nil {
203+ s.Logger.Error("pty-req reply", "err", err)
204+ }
205+}
206+
189207 // Write overrides the embedded Channel's Write to normalize line endings when PTY is allocated.
190208 func (s *SSHServerConnSession) Write(p []byte) (n int, err error) {
191209 s.mu.Lock()
......@@ -510,6 +528,14 @@ func NewSSHServer(ctx context.Context, logger *slog.Logger, config *SSHServerCon
510528 return nil
511529 }
512530
531+ // Clients send pty-req before shell or exec without waiting for the
532+ // reply, so it's applied here, before the next request is read.
533+ if req.Type == "pty-req" {
534+ sc.Logger.Info("new session request", "type", req.Type, "wantReply", req.WantReply, "payload", req.Payload)
535+ sesh.handlePtyReq(req)
536+ continue
537+ }
538+
513539 go func() {
514540 sc.Logger.Info("new session request", "type", req.Type, "wantReply", req.WantReply, "payload", req.Payload)
515541 switch req.Type {
......@@ -626,37 +652,6 @@ func NewSSHServer(ctx context.Context, logger *slog.Logger, config *SSHServerCon
626652 if err != nil {
627653 sc.Logger.Error("subsystem close", "err", err)
628654 }
629- case "pty-req":
630- sesh.mu.Lock()
631- found := sesh.pty != nil
632- sesh.mu.Unlock()
633- if found {
634- err := req.Reply(false, nil)
635- if err != nil {
636- sc.Logger.Error("pty-req reply", "err", err)
637- }
638- return
639- }
640-
641- ptyReq, ok := parsePtyRequest(req.Payload)
642- if !ok {
643- err := req.Reply(false, nil)
644- if err != nil {
645- sc.Logger.Error("pty-req reply", "err", err)
646- }
647- return
648- }
649-
650- sesh.mu.Lock()
651- sesh.pty = &ptyReq
652- sesh.winch = make(chan Window, 1)
653- sesh.mu.Unlock()
654-
655- sesh.winch <- ptyReq.Window
656- err := req.Reply(ok, nil)
657- if err != nil {
658- sc.Logger.Error("pty-req reply", "err", err)
659- }
660655 case "window-change":
661656 sesh.mu.Lock()
662657 found := sesh.pty != nil
+79, -0
......@@ -709,3 +709,82 @@ func TestSessionContextEndsWhenClientCloses(t *testing.T) {
709709 t.Fatal("command still running after the client closed its session")
710710 }
711711 }
712+
713+// OpenSSH sends pty-req and then shell or exec without waiting for the first
714+// reply, so the pty has to be set by the time the command's middleware runs.
715+func TestPtyVisibleToCommand(t *testing.T) {
716+ ctx, cancel := context.WithCancel(context.Background())
717+ defer cancel()
718+
719+ user := GenerateKey()
720+ server := pssh.NewSSHServer(ctx, slog.New(slog.DiscardHandler), &pssh.SSHServerConfig{
721+ ListenAddr: "127.0.0.1:0",
722+ Middleware: []pssh.SSHServerMiddleware{
723+ func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
724+ return func(sesh *pssh.SSHServerConnSession) error {
725+ if _, _, ok := sesh.Pty(); !ok {
726+ _, _ = sesh.Write([]byte("no pty"))
727+ }
728+ return next(sesh)
729+ }
730+ },
731+ },
732+ ServerConfig: &ssh.ServerConfig{
733+ NoClientAuth: true,
734+ NoClientAuthCallback: func(ssh.ConnMetadata) (*ssh.Permissions, error) {
735+ return &ssh.Permissions{Extensions: map[string]string{}}, nil
736+ },
737+ },
738+ })
739+ server.Config.AddHostKey(user.signer)
740+ go func() { _ = server.ListenAndServe() }()
741+
742+ var addr string
743+ for range 50 {
744+ server.Mu.Lock()
745+ if server.Listener != nil {
746+ addr = server.Listener.Addr().String()
747+ }
748+ server.Mu.Unlock()
749+ if addr != "" {
750+ break
751+ }
752+ time.Sleep(10 * time.Millisecond)
753+ }
754+ if addr == "" {
755+ t.Fatal("server listener not ready")
756+ }
757+
758+ client, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "user", HostKeyCallback: ssh.InsecureIgnoreHostKey()})
759+ if err != nil {
760+ t.Fatal(err)
761+ }
762+ defer func() { _ = client.Close() }()
763+
764+ ptyReq := ssh.Marshal(struct {
765+ Term string
766+ Columns, Rows uint32
767+ Width, Height uint32
768+ Modes string
769+ }{Term: "xterm", Columns: 80, Rows: 24})
770+
771+ missed := 0
772+ for range 200 {
773+ session, err := client.NewSession()
774+ if err != nil {
775+ t.Fatal(err)
776+ }
777+ if _, err := session.SendRequest("pty-req", false, ptyReq); err != nil {
778+ t.Fatal(err)
779+ }
780+ out, _ := session.Output("cmd")
781+ if strings.Contains(string(out), "no pty") {
782+ missed++
783+ }
784+ _ = session.Close()
785+ }
786+
787+ if missed > 0 {
788+ t.Fatalf("command ran before its pty request in %d of 200 sessions", missed)
789+ }
790+}