返回 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.Add(1)
150 go func() {
151 defer s.wg.Done()
152 s.handleConn(nConn)
153 }()
154 }
155 }
156
157 func (s *Server) handleConn(nConn net.Conn) {
158 sshConn, chans, reqs, err := ssh.NewServerConn(nConn, s.config)
159 if err != nil {
160 return
161 }
162 defer sshConn.Close()
163 go s.handleGlobalRequests(sshConn, reqs)
164 for newCh := range chans {
165 switch newCh.ChannelType() {
166 case "session":
167 go s.handleSession(newCh)
168 case "direct-tcpip":
169 go s.handleDirectTCPIP(newCh)
170 default:
171 _ = newCh.Reject(ssh.UnknownChannelType, "unsupported")
172 }
173 }
174 }
175
176 func (s *Server) handleGlobalRequests(conn *ssh.ServerConn, reqs <-chan *ssh.Request) {
177 for req := range reqs {
178 switch req.Type {
179 case "keepalive@openssh.com":
180 if req.WantReply {
181 _ = req.Reply(true, nil)
182 }
183 case "tcpip-forward":
184 s.handleTCPIPForward(conn, req)
185 case "cancel-tcpip-forward":
186 if req.WantReply {
187 _ = req.Reply(true, nil)
188 }
189 default:
190 if req.WantReply {
191 _ = req.Reply(false, nil)
192 }
193 }
194 }
195 }
196
197 func (s *Server) handleSession(newCh ssh.NewChannel) {
198 ch, reqs, err := newCh.Accept()
199 if err != nil {
200 return
201 }
202 defer ch.Close()
203 for req := range reqs {
204 switch req.Type {
205 case "exec":
206 cmd := parseStringPayload(req.Payload)
207 if req.WantReply {
208 _ = req.Reply(true, nil)
209 }
210 s.runExec(ch, cmd)
211 return
212 case "subsystem":
213 name := parseStringPayload(req.Payload)
214 if name == "sftp" && s.enableSFT {
215 if req.WantReply {
216 _ = req.Reply(true, nil)
217 }
218 s.runSFTP(ch)
219 return
220 }
221 if req.WantReply {
222 _ = req.Reply(false, nil)
223 }
224 case "shell", "pty-req", "env":
225 if req.WantReply {
226 _ = req.Reply(true, nil)
227 }
228 default:
229 if req.WantReply {
230 _ = req.Reply(false, nil)
231 }
232 }
233 }
234 }
235
236 func (s *Server) runExec(ch ssh.Channel, cmd string) {
237 stdout, stderr, exit := "", "", 0
238 if s.execFunc != nil {
239 stdout, stderr, exit = s.execFunc(cmd)
240 } else {
241 stdout = cmd
242 }
243 _, _ = io.WriteString(ch, stdout)
244 if stderr != "" {
245 _, _ = io.WriteString(ch.Stderr(), stderr)
246 }
247 sendExitStatus(ch, exit)
248 }
249
250 func (s *Server) runSFTP(ch ssh.Channel) {
251 var server *sftp.Server
252 var err error
253 if s.sftpRoot != "" {
254 server, err = sftp.NewServer(ch, sftp.WithServerWorkingDirectory(s.sftpRoot))
255 } else {
256 server, err = sftp.NewServer(ch)
257 }
258 if err != nil {
259 return
260 }
261 _ = server.Serve()
262 _ = server.Close()
263 }
264
265 // handleDirectTCPIP implements -L forwards: dial the requested target and
266 // splice.
267 func (s *Server) handleDirectTCPIP(newCh ssh.NewChannel) {
268 var payload struct {
269 HostToConnect string
270 PortToConnect uint32
271 OriginatorHost string
272 OriginatorPort uint32
273 }
274 if err := ssh.Unmarshal(newCh.ExtraData(), &payload); err != nil {
275 _ = newCh.Reject(ssh.ConnectionFailed, "bad payload")
276 return
277 }
278 target := net.JoinHostPort(payload.HostToConnect, fmt.Sprintf("%d", payload.PortToConnect))
279 dst, err := net.Dial("tcp", target)
280 if err != nil {
281 _ = newCh.Reject(ssh.ConnectionFailed, err.Error())
282 return
283 }
284 ch, reqs, err := newCh.Accept()
285 if err != nil {
286 _ = dst.Close()
287 return
288 }
289 go ssh.DiscardRequests(reqs)
290 splice(ch, dst)
291 }
292
293 // handleTCPIPForward implements -R forwards: listen locally on the server and
294 // open a forwarded-tcpip channel back to the client for each accepted conn.
295 func (s *Server) handleTCPIPForward(conn *ssh.ServerConn, req *ssh.Request) {
296 var payload struct {
297 BindAddr string
298 BindPort uint32
299 }
300 if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
301 if req.WantReply {
302 _ = req.Reply(false, nil)
303 }
304 return
305 }
306 ln, err := net.Listen("tcp", net.JoinHostPort(payload.BindAddr, fmt.Sprintf("%d", payload.BindPort)))
307 if err != nil {
308 if req.WantReply {
309 _ = req.Reply(false, nil)
310 }
311 return
312 }
313 boundPort := uint32(ln.Addr().(*net.TCPAddr).Port)
314 s.mu.Lock()
315 s.listeners = append(s.listeners, ln)
316 s.mu.Unlock()
317 if req.WantReply {
318 _ = req.Reply(true, ssh.Marshal(struct{ Port uint32 }{boundPort}))
319 }
320 go func() {
321 for {
322 c, err := ln.Accept()
323 if err != nil {
324 return
325 }
326 go func() {
327 origPort := uint32(1)
328 if ta, ok := c.RemoteAddr().(*net.TCPAddr); ok && ta.Port > 0 {
329 origPort = uint32(ta.Port)
330 }
331 msg := struct {
332 ConnHost string
333 ConnPort uint32
334 OrigHost string
335 OrigPort uint32
336 }{payload.BindAddr, boundPort, "127.0.0.1", origPort}
337 ch, reqs, err := conn.OpenChannel("forwarded-tcpip", ssh.Marshal(msg))
338 if err != nil {
339 _ = c.Close()
340 return
341 }
342 go ssh.DiscardRequests(reqs)
343 splice(ch, c)
344 }()
345 }
346 }()
347 }
348
349 func splice(a io.ReadWriteCloser, b net.Conn) {
350 done := make(chan struct{}, 2)
351 go func() { _, _ = io.Copy(a, b); done <- struct{}{} }()
352 go func() { _, _ = io.Copy(b, a); done <- struct{}{} }()
353 <-done
354 _ = a.Close()
355 _ = b.Close()
356 }
357
358 func parseStringPayload(p []byte) string {
359 if len(p) < 4 {
360 return ""
361 }
362 n := int(p[0])<<24 | int(p[1])<<16 | int(p[2])<<8 | int(p[3])
363 if 4+n > len(p) {
364 return ""
365 }
366 return string(p[4 : 4+n])
367 }
368
369 func sendExitStatus(ch ssh.Channel, code int) {
370 _, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
371 }
372
372 lines GO