Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.

Commit 8f6fd70

Browse files
committed
Add coder agent start
1 parent 05f9410 commit 8f6fd70

File tree

7 files changed

+579
-8
lines changed

7 files changed

+579
-8
lines changed

go.mod

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,35 @@ go 1.14
55
require (
66
cdr.dev/slog v1.4.0
77
cdr.dev/wsep v0.0.0-20200728013649-82316a09813f
8+
github.com/Microsoft/go-winio v0.4.16 // indirect
89
github.com/briandowns/spinner v1.12.0
10+
github.com/containerd/containerd v1.4.4 // indirect
11+
github.com/containerd/fifo v0.0.0-20210331061852-650e8a8a179d // indirect
12+
github.com/docker/distribution v2.7.1+incompatible // indirect
13+
github.com/docker/docker v20.10.5+incompatible
14+
github.com/docker/go-connections v0.4.0 // indirect
15+
github.com/docker/go-metrics v0.0.1 // indirect
16+
github.com/docker/go-units v0.4.0 // indirect
917
github.com/fatih/color v1.10.0
1018
github.com/google/go-cmp v0.5.5
1119
github.com/gorilla/websocket v1.4.2
20+
github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864
1221
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
1322
github.com/klauspost/compress v1.10.8 // indirect
1423
github.com/manifoldco/promptui v0.8.0
24+
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
25+
github.com/morikuni/aec v1.0.0 // indirect
26+
github.com/opencontainers/go-digest v1.0.0 // indirect
27+
github.com/opencontainers/image-spec v1.0.1 // indirect
28+
github.com/pion/webrtc/v3 v3.0.20
1529
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
1630
github.com/rjeczalik/notify v0.9.2
1731
github.com/spf13/cobra v1.1.3
18-
github.com/stretchr/testify v1.6.1 // indirect
19-
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
20-
golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect
32+
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
2133
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208
22-
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13
34+
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005
2335
golang.org/x/time v0.0.0-20191024005414-555d28b269f0
2436
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
37+
gotest.tools/v3 v3.0.3 // indirect
2538
nhooyr.io/websocket v1.8.6
2639
)

go.sum

Lines changed: 156 additions & 4 deletions
Large diffs are not rendered by default.

internal/cmd/agent.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package cmd
2+
3+
import (
4+
"cdr.dev/coder-cli/internal/x/xcobra"
5+
"cdr.dev/coder-cli/internal/x/xwebrtc"
6+
"cdr.dev/coder-cli/pkg/proto"
7+
"cdr.dev/slog"
8+
"cdr.dev/slog/sloggers/sloghuman"
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"github.com/hashicorp/yamux"
13+
"github.com/pion/webrtc/v3"
14+
"github.com/spf13/cobra"
15+
"golang.org/x/xerrors"
16+
"io"
17+
"net"
18+
"net/url"
19+
"nhooyr.io/websocket"
20+
"os"
21+
"strings"
22+
"time"
23+
)
24+
25+
func agentCmd() *cobra.Command {
26+
cmd := &cobra.Command{
27+
Use: "agent",
28+
Short: "Run the workspace agent",
29+
Long: "Connect to Coder and start running a p2p agent",
30+
Hidden: true,
31+
}
32+
33+
cmd.AddCommand(
34+
startCmd(),
35+
)
36+
return cmd
37+
}
38+
39+
func startCmd() *cobra.Command {
40+
var (
41+
token string
42+
)
43+
cmd := &cobra.Command{
44+
Use: "start [coderURL] --token=[token]",
45+
Args: xcobra.ExactArgs(1),
46+
Short: "starts the coder agent",
47+
Long: "starts the coder agent",
48+
Example: `# start the agent and connect with a Coder agent token
49+
50+
coder agent start https://my-coder.com --token xxxx-xxxx
51+
52+
# start the agent and use CODER_AGENT_TOKEN env var for auth token
53+
54+
coder agent start https://my-coder.com
55+
`,
56+
RunE: func(cmd *cobra.Command, args []string) error {
57+
ctx := cmd.Context()
58+
log := slog.Make(sloghuman.Sink(cmd.OutOrStdout()))
59+
60+
// Pull the URL from the args and do some sanity check.
61+
rawURL := args[0]
62+
if rawURL == "" || !strings.HasPrefix(rawURL, "http") {
63+
return xerrors.Errorf("invalid URL")
64+
}
65+
u, err := url.Parse(rawURL)
66+
if err != nil {
67+
return xerrors.Errorf("parse url: %w", err)
68+
}
69+
// Remove the trailing '/' if any.
70+
u.Path = "/api/private/envagent/listen"
71+
72+
if token == "" {
73+
var ok bool
74+
token, ok = os.LookupEnv("CODER_AGENT_TOKEN")
75+
if !ok {
76+
return xerrors.New("must pass --token or set the CODER_AGENT_TOKEN env variable")
77+
}
78+
}
79+
80+
q := u.Query()
81+
q.Set("agent_token", token)
82+
u.RawQuery = q.Encode()
83+
84+
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*15)
85+
defer cancelFunc()
86+
log.Info(ctx, "connecting to broker", slog.F("url", u.String()))
87+
conn, _, err := websocket.Dial(ctx, u.String(), nil)
88+
if err != nil {
89+
return fmt.Errorf("dial: %w", err)
90+
}
91+
nc := websocket.NetConn(context.Background(), conn, websocket.MessageBinary)
92+
session, err := yamux.Server(nc, nil)
93+
if err != nil {
94+
return fmt.Errorf("open: %w", err)
95+
}
96+
log.Info(ctx, "connected to broker. awaiting connection requests")
97+
for {
98+
st, err := session.AcceptStream()
99+
if err != nil {
100+
return fmt.Errorf("accept stream: %w", err)
101+
}
102+
stream := &stream{
103+
logger: log.Named(fmt.Sprintf("stream %d", st.StreamID())),
104+
stream: st,
105+
}
106+
go stream.listen()
107+
}
108+
},
109+
}
110+
111+
cmd.Flags().StringVar(&token, "token", "", "coder agent token")
112+
return cmd
113+
}
114+
115+
type stream struct {
116+
stream *yamux.Stream
117+
logger slog.Logger
118+
119+
rtc *webrtc.PeerConnection
120+
}
121+
122+
// writes an error and closes
123+
func (s *stream) fatal(err error) {
124+
s.write(proto.Message{
125+
Error: err.Error(),
126+
})
127+
s.logger.Error(context.Background(), err.Error(), slog.Error(err))
128+
s.stream.Close()
129+
}
130+
131+
func (s *stream) listen() {
132+
decoder := json.NewDecoder(s.stream)
133+
for {
134+
var msg proto.Message
135+
err := decoder.Decode(&msg)
136+
if err == io.EOF {
137+
break
138+
}
139+
if err != nil {
140+
s.fatal(err)
141+
return
142+
}
143+
s.processMessage(msg)
144+
}
145+
}
146+
147+
func (s *stream) write(msg proto.Message) error {
148+
d, err := json.Marshal(&msg)
149+
if err != nil {
150+
return err
151+
}
152+
_, err = s.stream.Write(d)
153+
if err != nil {
154+
return err
155+
}
156+
return nil
157+
}
158+
159+
func (s *stream) processMessage(msg proto.Message) {
160+
s.logger.Debug(context.Background(), "processing message", slog.F("msg", msg))
161+
162+
if msg.Error != "" {
163+
s.fatal(xerrors.New(msg.Error))
164+
return
165+
}
166+
167+
if msg.Candidate != "" {
168+
if s.rtc == nil {
169+
s.fatal(xerrors.New("rtc connection must be started before candidates are sent"))
170+
return
171+
}
172+
173+
s.logger.Debug(context.Background(), "accepted ice candidate", slog.F("candidate", msg.Candidate))
174+
err := proto.AcceptICECandidate(s.rtc, &msg)
175+
if err != nil {
176+
s.fatal(err)
177+
return
178+
}
179+
}
180+
181+
if msg.Offer != nil {
182+
rtc, err := xwebrtc.NewPeerConnection()
183+
if err != nil {
184+
s.fatal(fmt.Errorf("create connection: %w", err))
185+
return
186+
}
187+
flushCandidates := proto.ProxyICECandidates(rtc, s.stream)
188+
189+
err = rtc.SetRemoteDescription(*msg.Offer)
190+
if err != nil {
191+
s.fatal(fmt.Errorf("set remote desc: %w", err))
192+
return
193+
}
194+
answer, err := rtc.CreateAnswer(nil)
195+
if err != nil {
196+
s.fatal(fmt.Errorf("create answer: %w", err))
197+
return
198+
}
199+
err = rtc.SetLocalDescription(answer)
200+
if err != nil {
201+
s.fatal(fmt.Errorf("set local desc: %w", err))
202+
return
203+
}
204+
flushCandidates()
205+
206+
err = s.write(proto.Message{
207+
Answer: rtc.LocalDescription(),
208+
})
209+
if err != nil {
210+
s.fatal(fmt.Errorf("send local desc: %w", err))
211+
return
212+
}
213+
214+
rtc.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) {
215+
s.logger.Info(context.Background(), "state changed", slog.F("new", pcs))
216+
})
217+
rtc.OnDataChannel(s.processDataChannel)
218+
s.rtc = rtc
219+
}
220+
}
221+
222+
func (s *stream) processDataChannel(channel *webrtc.DataChannel) {
223+
if channel.Protocol() == "ping" {
224+
channel.OnOpen(func() {
225+
rw, err := channel.Detach()
226+
if err != nil {
227+
return
228+
}
229+
d := make([]byte, 64)
230+
_, err = rw.Read(d)
231+
rw.Write(d)
232+
})
233+
return
234+
}
235+
236+
proto, port, err := xwebrtc.ParseProxyDataChannel(channel)
237+
if proto != "tcp" {
238+
s.fatal(fmt.Errorf("client provided unsupported protocol: %s", proto))
239+
return
240+
}
241+
242+
conn, err := net.Dial(proto, fmt.Sprintf("localhost:%d", port))
243+
if err != nil {
244+
s.fatal(fmt.Errorf("failed to dial client port: %d", port))
245+
return
246+
}
247+
248+
channel.OnOpen(func() {
249+
s.logger.Debug(context.Background(), "proxying data channel to local port", slog.F("port", port))
250+
rw, err := channel.Detach()
251+
if err != nil {
252+
channel.Close()
253+
s.logger.Error(context.Background(), "detach client data channel", slog.Error(err))
254+
return
255+
}
256+
go io.Copy(rw, conn)
257+
go io.Copy(conn, rw)
258+
})
259+
}

internal/cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func Make() *cobra.Command {
3737
imgsCmd(),
3838
providersCmd(),
3939
genDocsCmd(app),
40+
agentCmd(),
4041
)
4142
app.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "show verbose output")
4243
return app

internal/x/xwebrtc/channel.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package xwebrtc
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net"
8+
"strconv"
9+
"time"
10+
11+
"github.com/pion/webrtc/v3"
12+
)
13+
14+
// WaitForDataChannelOpen waits for the data channel to have the open state.
15+
// By default, it waits 15 seconds.
16+
func WaitForDataChannelOpen(ctx context.Context, channel *webrtc.DataChannel) error {
17+
if channel.ReadyState() == webrtc.DataChannelStateOpen {
18+
return nil
19+
}
20+
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*15)
21+
defer cancelFunc()
22+
channel.OnOpen(func() {
23+
cancelFunc()
24+
})
25+
<-ctx.Done()
26+
if ctx.Err() == context.DeadlineExceeded {
27+
return ctx.Err()
28+
}
29+
return nil
30+
}
31+
32+
// NewProxyDataChannel creates a new data channel for proxying.
33+
func NewProxyDataChannel(conn *webrtc.PeerConnection, name, protocol string, port uint16) (*webrtc.DataChannel, error) {
34+
proto := fmt.Sprintf("%s:%d", protocol, port)
35+
ordered := true
36+
return conn.CreateDataChannel(name, &webrtc.DataChannelInit{
37+
Protocol: &proto,
38+
Ordered: &ordered,
39+
})
40+
}
41+
42+
// ParseProxyDataChannel parses a data channel to get the protocol and port.
43+
func ParseProxyDataChannel(channel *webrtc.DataChannel) (string, uint16, error) {
44+
if channel.Protocol() == "" {
45+
return "", 0, errors.New("data channel is not a proxy")
46+
}
47+
host, port, err := net.SplitHostPort(channel.Protocol())
48+
if err != nil {
49+
return "", 0, fmt.Errorf("split protocol: %w", err)
50+
}
51+
p, err := strconv.ParseInt(port, 10, 16)
52+
if err != nil {
53+
return "", 0, fmt.Errorf("parse port: %w", err)
54+
}
55+
return host, uint16(p), nil
56+
}

internal/x/xwebrtc/conn.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package xwebrtc
2+
3+
import "github.com/pion/webrtc/v3"
4+
5+
// NewPeerConnection creates a new peer connection.
6+
// It uses the Google stun server by default.
7+
func NewPeerConnection() (*webrtc.PeerConnection, error) {
8+
se := webrtc.SettingEngine{}
9+
se.DetachDataChannels()
10+
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
11+
12+
return api.NewPeerConnection(webrtc.Configuration{
13+
ICEServers: []webrtc.ICEServer{
14+
{
15+
URLs: []string{"stun:stun.l.google.com:19302"},
16+
},
17+
},
18+
})
19+
}

0 commit comments

Comments
 (0)