Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit91bf863

Browse files
authored
feat: Add workspace agent for SSH (#318)
* feat: Add workspace agent for SSHThis adds the initial agent that supports TTYand execution over SSH. It functions across MacOS,Windows, and Linux.This does not handle the coderd interaction yet,but does setup a simple path forward.* Fix pty tests on Windows* Fix log race* Lock around dial error to fix log output* Fix context return early* fix: Leaking yamux session after HTTP handler is closedCloses#317. We depended on the context canceling the yamux connection,but this isn't a sync operation. Explicitly calling close ensures thehandler waits for yamux to complete before exit.* Lock around close return* Force failure with log* Fix failed handler* Upgrade dep* Fix defer inside loops* Fix context cancel for HTTP requests* Fix resize
1 parent65de96c commit91bf863

18 files changed

+572
-37
lines changed

‎agent/agent.go‎

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net"
11+
"os/exec"
12+
"os/user"
13+
"sync"
14+
"time"
15+
16+
"cdr.dev/slog"
17+
"github.com/coder/coder/agent/usershell"
18+
"github.com/coder/coder/peer"
19+
"github.com/coder/coder/peerbroker"
20+
"github.com/coder/coder/pty"
21+
"github.com/coder/retry"
22+
23+
"github.com/gliderlabs/ssh"
24+
gossh"golang.org/x/crypto/ssh"
25+
"golang.org/x/xerrors"
26+
)
27+
28+
funcDialSSH(conn*peer.Conn) (net.Conn,error) {
29+
channel,err:=conn.Dial(context.Background(),"ssh",&peer.ChannelOptions{
30+
Protocol:"ssh",
31+
})
32+
iferr!=nil {
33+
returnnil,err
34+
}
35+
returnchannel.NetConn(),nil
36+
}
37+
38+
funcDialSSHClient(conn*peer.Conn) (*gossh.Client,error) {
39+
netConn,err:=DialSSH(conn)
40+
iferr!=nil {
41+
returnnil,err
42+
}
43+
sshConn,channels,requests,err:=gossh.NewClientConn(netConn,"localhost:22",&gossh.ClientConfig{
44+
Config: gossh.Config{
45+
Ciphers: []string{"arcfour"},
46+
},
47+
// SSH host validation isn't helpful, because obtaining a peer
48+
// connection already signifies user-intent to dial a workspace.
49+
// #nosec
50+
HostKeyCallback:gossh.InsecureIgnoreHostKey(),
51+
})
52+
iferr!=nil {
53+
returnnil,err
54+
}
55+
returngossh.NewClient(sshConn,channels,requests),nil
56+
}
57+
58+
typeOptionsstruct {
59+
Logger slog.Logger
60+
}
61+
62+
typeDialerfunc(ctx context.Context) (*peerbroker.Listener,error)
63+
64+
funcNew(dialerDialer,options*Options) io.Closer {
65+
ctx,cancelFunc:=context.WithCancel(context.Background())
66+
server:=&server{
67+
clientDialer:dialer,
68+
options:options,
69+
closeCancel:cancelFunc,
70+
closed:make(chanstruct{}),
71+
}
72+
server.init(ctx)
73+
returnserver
74+
}
75+
76+
typeserverstruct {
77+
clientDialerDialer
78+
options*Options
79+
80+
closeCancel context.CancelFunc
81+
closeMutex sync.Mutex
82+
closedchanstruct{}
83+
84+
sshServer*ssh.Server
85+
}
86+
87+
func (s*server)init(ctx context.Context) {
88+
// Clients' should ignore the host key when connecting.
89+
// The agent needs to authenticate with coderd to SSH,
90+
// so SSH authentication doesn't improve security.
91+
randomHostKey,err:=rsa.GenerateKey(rand.Reader,2048)
92+
iferr!=nil {
93+
panic(err)
94+
}
95+
randomSigner,err:=gossh.NewSignerFromKey(randomHostKey)
96+
iferr!=nil {
97+
panic(err)
98+
}
99+
sshLogger:=s.options.Logger.Named("ssh-server")
100+
forwardHandler:=&ssh.ForwardedTCPHandler{}
101+
s.sshServer=&ssh.Server{
102+
ChannelHandlers:ssh.DefaultChannelHandlers,
103+
ConnectionFailedCallback:func(conn net.Conn,errerror) {
104+
sshLogger.Info(ctx,"ssh connection ended",slog.Error(err))
105+
},
106+
Handler:func(session ssh.Session) {
107+
err:=s.handleSSHSession(session)
108+
iferr!=nil {
109+
s.options.Logger.Debug(ctx,"ssh session failed",slog.Error(err))
110+
_=session.Exit(1)
111+
return
112+
}
113+
},
114+
HostSigners: []ssh.Signer{randomSigner},
115+
LocalPortForwardingCallback:func(ctx ssh.Context,destinationHoststring,destinationPortuint32)bool {
116+
// Allow local port forwarding all!
117+
sshLogger.Debug(ctx,"local port forward",
118+
slog.F("destination-host",destinationHost),
119+
slog.F("destination-port",destinationPort))
120+
returntrue
121+
},
122+
PtyCallback:func(ctx ssh.Context,pty ssh.Pty)bool {
123+
returntrue
124+
},
125+
ReversePortForwardingCallback:func(ctx ssh.Context,bindHoststring,bindPortuint32)bool {
126+
// Allow reverse port forwarding all!
127+
sshLogger.Debug(ctx,"local port forward",
128+
slog.F("bind-host",bindHost),
129+
slog.F("bind-port",bindPort))
130+
returntrue
131+
},
132+
RequestHandlers:map[string]ssh.RequestHandler{
133+
"tcpip-forward":forwardHandler.HandleSSHRequest,
134+
"cancel-tcpip-forward":forwardHandler.HandleSSHRequest,
135+
},
136+
ServerConfigCallback:func(ctx ssh.Context)*gossh.ServerConfig {
137+
return&gossh.ServerConfig{
138+
Config: gossh.Config{
139+
// "arcfour" is the fastest SSH cipher. We prioritize throughput
140+
// over encryption here, because the WebRTC connection is already
141+
// encrypted. If possible, we'd disable encryption entirely here.
142+
Ciphers: []string{"arcfour"},
143+
},
144+
NoClientAuth:true,
145+
}
146+
},
147+
}
148+
149+
gos.run(ctx)
150+
}
151+
152+
func (*server)handleSSHSession(session ssh.Session)error {
153+
var (
154+
commandstring
155+
args= []string{}
156+
errerror
157+
)
158+
159+
username:=session.User()
160+
ifusername=="" {
161+
currentUser,err:=user.Current()
162+
iferr!=nil {
163+
returnxerrors.Errorf("get current user: %w",err)
164+
}
165+
username=currentUser.Username
166+
}
167+
168+
// gliderlabs/ssh returns a command slice of zero
169+
// when a shell is requested.
170+
iflen(session.Command())==0 {
171+
command,err=usershell.Get(username)
172+
iferr!=nil {
173+
returnxerrors.Errorf("get user shell: %w",err)
174+
}
175+
}else {
176+
command=session.Command()[0]
177+
iflen(session.Command())>1 {
178+
args=session.Command()[1:]
179+
}
180+
}
181+
182+
signals:=make(chan ssh.Signal)
183+
breaks:=make(chanbool)
184+
deferclose(signals)
185+
deferclose(breaks)
186+
gofunc() {
187+
for {
188+
select {
189+
case<-session.Context().Done():
190+
return
191+
// Ignore signals and breaks for now!
192+
case<-signals:
193+
case<-breaks:
194+
}
195+
}
196+
}()
197+
198+
cmd:=exec.CommandContext(session.Context(),command,args...)
199+
cmd.Env=session.Environ()
200+
201+
sshPty,windowSize,isPty:=session.Pty()
202+
ifisPty {
203+
cmd.Env=append(cmd.Env,fmt.Sprintf("TERM=%s",sshPty.Term))
204+
ptty,process,err:=pty.Start(cmd)
205+
iferr!=nil {
206+
returnxerrors.Errorf("start command: %w",err)
207+
}
208+
gofunc() {
209+
forwin:=rangewindowSize {
210+
err:=ptty.Resize(uint16(win.Width),uint16(win.Height))
211+
iferr!=nil {
212+
panic(err)
213+
}
214+
}
215+
}()
216+
gofunc() {
217+
_,_=io.Copy(ptty.Input(),session)
218+
}()
219+
gofunc() {
220+
_,_=io.Copy(session,ptty.Output())
221+
}()
222+
_,_=process.Wait()
223+
_=ptty.Close()
224+
returnnil
225+
}
226+
227+
cmd.Stdout=session
228+
cmd.Stderr=session
229+
// This blocks forever until stdin is received if we don't
230+
// use StdinPipe. It's unknown what causes this.
231+
stdinPipe,err:=cmd.StdinPipe()
232+
iferr!=nil {
233+
returnxerrors.Errorf("create stdin pipe: %w",err)
234+
}
235+
gofunc() {
236+
_,_=io.Copy(stdinPipe,session)
237+
}()
238+
err=cmd.Start()
239+
iferr!=nil {
240+
returnxerrors.Errorf("start: %w",err)
241+
}
242+
_=cmd.Wait()
243+
returnnil
244+
}
245+
246+
func (s*server)run(ctx context.Context) {
247+
varpeerListener*peerbroker.Listener
248+
varerrerror
249+
// An exponential back-off occurs when the connection is failing to dial.
250+
// This is to prevent server spam in case of a coderd outage.
251+
forretrier:=retry.New(50*time.Millisecond,10*time.Second);retrier.Wait(ctx); {
252+
peerListener,err=s.clientDialer(ctx)
253+
iferr!=nil {
254+
iferrors.Is(err,context.Canceled) {
255+
return
256+
}
257+
ifs.isClosed() {
258+
return
259+
}
260+
s.options.Logger.Warn(context.Background(),"failed to dial",slog.Error(err))
261+
continue
262+
}
263+
s.options.Logger.Debug(context.Background(),"connected")
264+
break
265+
}
266+
select {
267+
case<-ctx.Done():
268+
return
269+
default:
270+
}
271+
272+
for {
273+
conn,err:=peerListener.Accept()
274+
iferr!=nil {
275+
ifs.isClosed() {
276+
return
277+
}
278+
s.options.Logger.Debug(ctx,"peer listener accept exited; restarting connection",slog.Error(err))
279+
s.run(ctx)
280+
return
281+
}
282+
gos.handlePeerConn(ctx,conn)
283+
}
284+
}
285+
286+
func (s*server)handlePeerConn(ctx context.Context,conn*peer.Conn) {
287+
for {
288+
channel,err:=conn.Accept(ctx)
289+
iferr!=nil {
290+
iferrors.Is(err,peer.ErrClosed)||s.isClosed() {
291+
return
292+
}
293+
s.options.Logger.Debug(ctx,"accept channel from peer connection",slog.Error(err))
294+
return
295+
}
296+
297+
switchchannel.Protocol() {
298+
case"ssh":
299+
s.sshServer.HandleConn(channel.NetConn())
300+
default:
301+
s.options.Logger.Warn(ctx,"unhandled protocol from channel",
302+
slog.F("protocol",channel.Protocol()),
303+
slog.F("label",channel.Label()),
304+
)
305+
}
306+
}
307+
}
308+
309+
// isClosed returns whether the API is closed or not.
310+
func (s*server)isClosed()bool {
311+
select {
312+
case<-s.closed:
313+
returntrue
314+
default:
315+
returnfalse
316+
}
317+
}
318+
319+
func (s*server)Close()error {
320+
s.closeMutex.Lock()
321+
defers.closeMutex.Unlock()
322+
ifs.isClosed() {
323+
returnnil
324+
}
325+
close(s.closed)
326+
s.closeCancel()
327+
_=s.sshServer.Close()
328+
returnnil
329+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp