返回 DeepSeek-Reasonix
sshtest.go
根目录 / internal / remote / sshtest / sshtest.go
1 // Package sshtest is an in-process SSH server for exercising the remote
2 // module without a real sshd. It supports publickey and password auth, session
3 // exec with scripted responses, direct-tcpip (for -L forwards), tcpip-forward
4 // (for -R forwards), and an SFTP subsystem via pkg/sftp's server. It is
5 // test-only.
6 package sshtest
7
8 import (
9 "errors"
10 "fmt"
11 "io"
12 "net"
13 "sync"
14 "testing"
15
16 "github.com/pkg/sftp"
17 "golang.org/x/crypto/ssh"
18 )
19
20 // Server is a running in-process SSH server.
21 type Server struct {
22 Addr string
23 HostKey ssh.Signer
24 config *ssh.ServerConfig
25 listener net.Listener
26 execFunc func(cmd string) (stdout string, stderr string, exit int)
27 sftpRoot string
28 enableSFT bool
29
30 mu sync.Mutex
31 conns []net.Conn
32 listeners []net.Listener
33 wg sync.WaitGroup
34 }
35
36 // Options configures a test server.
37 type Options struct {
38 // HostKeys, when non-empty, are offered by the server. The first key is
39 // also exposed as Server.HostKey. Empty generates one ed25519 key.
40 HostKeys []ssh.Signer
41 // Password, when non-empty, enables password auth accepting (any user,
42 // this password).
43 Password string
44 // AuthorizedKey, when set, enables publickey auth accepting this key.
45 AuthorizedKey ssh.PublicKey
46 // Exec handles `exec` requests; nil => a default echoing the command.
47 Exec func(cmd string) (stdout string, stderr string, exit int)
48 // SFTPRoot enables the SFTP subsystem rooted at this directory.
49 SFTPRoot string
50 }
51
52 // Start launches a server on 127.0.0.1:0.
53 func Start(t *testing.T, opts Options) *Server {
54 t.Helper()
55 hostKeys := opts.HostKeys
56 if len(hostKeys) == 0 {
57 hostKey, err := generateHostKey()
58 if err != nil {
59 t.Fatalf("host key: %v", err)
60 }
61 hostKeys = []ssh.Signer{hostKey}
62 }
63 cfg := &ssh.ServerConfig{}
64 for _, hostKey := range hostKeys {
65 if hostKey == nil {
66 t.Fatal("host key must not be nil")
67 }
68 cfg.AddHostKey(hostKey)
69 }
70 if opts.Password != "" {
71 cfg.PasswordCallback = func(conn ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
72 if string(pass) == opts.Password {
73 return &ssh.Permissions{}, nil
74 }
75 return nil, errors.New("bad password")
76 }
77 }
78 if opts.AuthorizedKey != nil {
79 want := opts.AuthorizedKey.Marshal()
80 cfg.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
81 if string(key.Marshal()) == string(want) {
82 return &ssh.Permissions{}, nil
83 }
84 return nil, errors.New("unknown key")
85 }
86 }
87 if opts.Password == "" && opts.AuthorizedKey == nil {
88 cfg.NoClientAuth = true
89 }
90
91 ln, err := net.Listen("tcp", "127.0.0.1:0")
92 if err != nil {
93 t.Fatalf("listen: %v", err)
94 }
95 s := &Server{
96 Addr: ln.Addr().String(),
97 HostKey: hostKeys[0],
98 config: cfg,
99 listener: ln,
100 execFunc: opts.Exec,
101 sftpRoot: opts.SFTPRoot,
102 enableSFT: opts.SFTPRoot != "",
103 }
104 s.wg.Add(1)
105 go s.serve()
106 t.Cleanup(s.Close)
107 return s
108 }
109
110 // Close stops the server and all active connections.
111 func (s *Server) Close() {
112 _ = s.listener.Close()
113 s.mu.Lock()
114 for _, c := range s.conns {
115 _ = c.Close()
116 }
117 for _, ln := range s.listeners {
118 _ = ln.Close()
119 }
120 s.conns = nil
121 s.listeners = nil
122 s.mu.Unlock()
123 s.wg.Wait()
124 }
125
126 // DropConnections closes every currently-open client connection without
127 // stopping the server, simulating a network drop so a supervised Client must
128 // reconnect.
129 func (s *Server) DropConnections() {
130 s.mu.Lock()
131 conns := s.conns
132 s.conns = nil
133 s.mu.Unlock()
134 for _, c := range conns {
135 _ = c.Close()
136 }
137 }
138
139 func (s *Server) serve() {
140 defer s.wg.Done()
141 for {
142 nConn, err := s.listener.Accept()
143 if err != nil {
144 return
145 }
146 s.mu.Lock()
147 s.conns = append(s.conns, nConn)
148 s.mu.Unlock()
149 s.wg.Go(func() {
150 s.handleConn(nConn)
151 })
152 }
153 }
154
155 func (s *Server) handleConn(nConn net.Conn) {
156 sshConn, chans, reqs, err := ssh.NewServerConn(nConn, s.config)
157 if err != nil {
158 return
159 }
160 defer sshConn.Close()
161 go s.handleGlobalRequests(sshConn, reqs)
162 for newCh := range chans {
163 switch newCh.ChannelType() {
164 case "session":
165 go s.handleSession(newCh)
166 case "direct-tcpip":
167 go s.handleDirectTCPIP(newCh)
168 default:
169 _ = newCh.Reject(ssh.UnknownChannelType, "unsupported")
170 }
171 }
172 }
173
174 func (s *Server) handleGlobalRequests(conn *ssh.ServerConn, reqs <-chan *ssh.Request) {
175 for req := range reqs {
176 switch req.Type {
177 case "keepalive@openssh.com":
178 if req.WantReply {
179 _ = req.Reply(true, nil)
180 }
181 case "tcpip-forward":
182 s.handleTCPIPForward(conn, req)
183 case "cancel-tcpip-forward":
184 if req.WantReply {
185 _ = req.Reply(true, nil)
186 }
187 default:
188 if req.WantReply {
189 _ = req.Reply(false, nil)
190 }
191 }
192 }
193 }
194
195 func (s *Server) handleSession(newCh ssh.NewChannel) {
196 ch, reqs, err := newCh.Accept()
197 if err != nil {
198 return
199 }
200 defer ch.Close()
201 for req := range reqs {
202 switch req.Type {
203 case "exec":
204 cmd := parseStringPayload(req.Payload)
205 if req.WantReply {
206 _ = req.Reply(true, nil)
207 }
208 s.runExec(ch, cmd)
209 return
210 case "subsystem":
211 name := parseStringPayload(req.Payload)
212 if name == "sftp" && s.enableSFT {
213 if req.WantReply {
214 _ = req.Reply(true, nil)
215 }
216 s.runSFTP(ch)
217 return
218 }
219 if req.WantReply {
220 _ = req.Reply(false, nil)
221 }
222 case "shell", "pty-req", "env":
223 if req.WantReply {
224 _ = req.Reply(true, nil)
225 }
226 default:
227 if req.WantReply {
228 _ = req.Reply(false, nil)
229 }
230 }
231 }
232 }
233
234 func (s *Server) runExec(ch ssh.Channel, cmd string) {
235 stdout, stderr, exit := "", "", 0
236 if s.execFunc != nil {
237 stdout, stderr, exit = s.execFunc(cmd)
238 } else {
239 stdout = cmd
240 }
241 _, _ = io.WriteString(ch, stdout)
242 if stderr != "" {
243 _, _ = io.WriteString(ch.Stderr(), stderr)
244 }
245 sendExitStatus(ch, exit)
246 }
247
248 func (s *Server) runSFTP(ch ssh.Channel) {
249 var server *sftp.Server
250 var err error
251 if s.sftpRoot != "" {
252 server, err = sftp.NewServer(ch, sftp.WithServerWorkingDirectory(s.sftpRoot))
253 } else {
254 server, err = sftp.NewServer(ch)
255 }
256 if err != nil {
257 return
258 }
259 _ = server.Serve()
260 _ = server.Close()
261 }
262
263 // handleDirectTCPIP implements -L forwards: dial the requested target and
264 // splice.
265 func (s *Server) handleDirectTCPIP(newCh ssh.NewChannel) {
266 var payload struct {
267 HostToConnect string
268 PortToConnect uint32
269 OriginatorHost string
270 OriginatorPort uint32
271 }
272 if err := ssh.Unmarshal(newCh.ExtraData(), &payload); err != nil {
273 _ = newCh.Reject(ssh.ConnectionFailed, "bad payload")
274 return
275 }
276 target := net.JoinHostPort(payload.HostToConnect, fmt.Sprintf("%d", payload.PortToConnect))
277 dst, err := net.Dial("tcp", target)
278 if err != nil {
279 _ = newCh.Reject(ssh.ConnectionFailed, err.Error())
280 return
281 }
282 ch, reqs, err := newCh.Accept()
283 if err != nil {
284 _ = dst.Close()
285 return
286 }
287 go ssh.DiscardRequests(reqs)
288 splice(ch, dst)
289 }
290
291 // handleTCPIPForward implements -R forwards: listen locally on the server and
292 // open a forwarded-tcpip channel back to the client for each accepted conn.
293 func (s *Server) handleTCPIPForward(conn *ssh.ServerConn, req *ssh.Request) {
294 var payload struct {
295 BindAddr string
296 BindPort uint32
297 }
298 if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
299 if req.WantReply {
300 _ = req.Reply(false, nil)
301 }
302 return
303 }
304 ln, err := net.Listen("tcp", net.JoinHostPort(payload.BindAddr, fmt.Sprintf("%d", payload.BindPort)))
305 if err != nil {
306 if req.WantReply {
307 _ = req.Reply(false, nil)
308 }
309 return
310 }
311 boundPort := uint32(ln.Addr().(*net.TCPAddr).Port)
312 s.mu.Lock()
313 s.listeners = append(s.listeners, ln)
314 s.mu.Unlock()
315 if req.WantReply {
316 _ = req.Reply(true, ssh.Marshal(struct{ Port uint32 }{boundPort}))
317 }
318 go func() {
319 for {
320 c, err := ln.Accept()
321 if err != nil {
322 return
323 }
324 go func() {
325 origPort := uint32(1)
326 if ta, ok := c.RemoteAddr().(*net.TCPAddr); ok && ta.Port > 0 {
327 origPort = uint32(ta.Port)
328 }
329 msg := struct {
330 ConnHost string
331 ConnPort uint32
332 OrigHost string
333 OrigPort uint32
334 }{payload.BindAddr, boundPort, "127.0.0.1", origPort}
335 ch, reqs, err := conn.OpenChannel("forwarded-tcpip", ssh.Marshal(msg))
336 if err != nil {
337 _ = c.Close()
338 return
339 }
340 go ssh.DiscardRequests(reqs)
341 splice(ch, c)
342 }()
343 }
344 }()
345 }
346
347 func splice(a io.ReadWriteCloser, b net.Conn) {
348 done := make(chan struct{}, 2)
349 go func() { _, _ = io.Copy(a, b); done <- struct{}{} }()
350 go func() { _, _ = io.Copy(b, a); done <- struct{}{} }()
351 <-done
352 _ = a.Close()
353 _ = b.Close()
354 }
355
356 func parseStringPayload(p []byte) string {
357 if len(p) < 4 {
358 return ""
359 }
360 n := int(p[0])<<24 | int(p[1])<<16 | int(p[2])<<8 | int(p[3])
361 if 4+n > len(p) {
362 return ""
363 }
364 return string(p[4 : 4+n])
365 }
366
367 func sendExitStatus(ch ssh.Channel, code int) {
368 _, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
369 }
370
370 lines GO