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

Commit7ec58fb

Browse files
committed
feat: Add workspace agent for SSH
This 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.
1 parente5db936 commit7ec58fb

File tree

12 files changed

+508
-28
lines changed

12 files changed

+508
-28
lines changed

‎agent/agent.go‎

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

‎agent/agent_test.go‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package agent_test
2+
3+
import (
4+
"context"
5+
"runtime"
6+
"strings"
7+
"testing"
8+
9+
"github.com/pion/webrtc/v3"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/goleak"
12+
"golang.org/x/crypto/ssh"
13+
14+
"cdr.dev/slog/sloggers/slogtest"
15+
"github.com/coder/coder/agent"
16+
"github.com/coder/coder/peer"
17+
"github.com/coder/coder/peerbroker"
18+
"github.com/coder/coder/peerbroker/proto"
19+
"github.com/coder/coder/provisionersdk"
20+
"github.com/coder/coder/pty/ptytest"
21+
)
22+
23+
funcTestMain(m*testing.M) {
24+
goleak.VerifyTestMain(m)
25+
}
26+
27+
funcTestAgent(t*testing.T) {
28+
t.Parallel()
29+
t.Run("SessionExec",func(t*testing.T) {
30+
t.Parallel()
31+
api:=setup(t)
32+
stream,err:=api.NegotiateConnection(context.Background())
33+
require.NoError(t,err)
34+
conn,err:=peerbroker.Dial(stream, []webrtc.ICEServer{},&peer.ConnOptions{
35+
Logger:slogtest.Make(t,nil),
36+
})
37+
require.NoError(t,err)
38+
deferconn.Close()
39+
sshClient,err:=agent.DialSSHClient(conn)
40+
require.NoError(t,err)
41+
session,err:=sshClient.NewSession()
42+
require.NoError(t,err)
43+
command:="echo test"
44+
ifruntime.GOOS=="windows" {
45+
command="cmd.exe /c echo test"
46+
}
47+
output,err:=session.Output(command)
48+
require.NoError(t,err)
49+
require.Equal(t,"test",strings.TrimSpace(string(output)))
50+
})
51+
52+
t.Run("SessionTTY",func(t*testing.T) {
53+
t.Parallel()
54+
api:=setup(t)
55+
stream,err:=api.NegotiateConnection(context.Background())
56+
require.NoError(t,err)
57+
conn,err:=peerbroker.Dial(stream, []webrtc.ICEServer{},&peer.ConnOptions{
58+
Logger:slogtest.Make(t,nil),
59+
})
60+
require.NoError(t,err)
61+
deferconn.Close()
62+
sshClient,err:=agent.DialSSHClient(conn)
63+
require.NoError(t,err)
64+
session,err:=sshClient.NewSession()
65+
require.NoError(t,err)
66+
prompt:="$"
67+
command:="bash"
68+
ifruntime.GOOS=="windows" {
69+
command="cmd.exe"
70+
prompt=">"
71+
}
72+
err=session.RequestPty("xterm",128,128, ssh.TerminalModes{})
73+
require.NoError(t,err)
74+
ptty:=ptytest.New(t)
75+
require.NoError(t,err)
76+
session.Stdout=ptty.Output()
77+
session.Stderr=ptty.Output()
78+
session.Stdin=ptty.Input()
79+
err=session.Start(command)
80+
require.NoError(t,err)
81+
ptty.ExpectMatch(prompt)
82+
ptty.WriteLine("echo test")
83+
ptty.ExpectMatch("test")
84+
ptty.WriteLine("exit")
85+
err=session.Wait()
86+
require.NoError(t,err)
87+
})
88+
}
89+
90+
funcsetup(t*testing.T) proto.DRPCPeerBrokerClient {
91+
client,server:=provisionersdk.TransportPipe()
92+
closer:=agent.New(func(ctx context.Context) (*peerbroker.Listener,error) {
93+
returnpeerbroker.Listen(server,&peer.ConnOptions{
94+
Logger:slogtest.Make(t,nil),
95+
})
96+
},&agent.Options{
97+
Logger:slogtest.Make(t,nil),
98+
})
99+
t.Cleanup(func() {
100+
_=client.Close()
101+
_=server.Close()
102+
_=closer.Close()
103+
})
104+
returnproto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client))
105+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp