1// Package liveshare is a Go client library for the Visual Studio Live Share 2// service, which provides collaborative, distibuted editing and debugging. 3// See https://docs.microsoft.com/en-us/visualstudio/liveshare for an overview. 4// 5// It provides the ability for a Go program to connect to a Live Share 6// workspace (Connect), to expose a TCP port on a remote host 7// (UpdateSharedVisibility), to start an SSH server listening on an 8// exposed port (StartSSHServer), and to forward connections between 9// the remote port and a local listening TCP port (ForwardToListener) 10// or a local Go reader/writer (Forward). 11package liveshare 12 13import ( 14 "context" 15 "crypto/tls" 16 "errors" 17 "fmt" 18 "net/url" 19 "strings" 20 "time" 21 22 "github.com/opentracing/opentracing-go" 23 "golang.org/x/crypto/ssh" 24) 25 26type logger interface { 27 Println(v ...interface{}) 28 Printf(f string, v ...interface{}) 29} 30 31// An Options specifies Live Share connection parameters. 32type Options struct { 33 ClientName string // ClientName is the name of the connecting client. 34 SessionID string 35 SessionToken string // token for SSH session 36 RelaySAS string 37 RelayEndpoint string 38 HostPublicKeys []string 39 Logger logger // required 40 TLSConfig *tls.Config // (optional) 41} 42 43// uri returns a websocket URL for the specified options. 44func (opts *Options) uri(action string) (string, error) { 45 if opts.ClientName == "" { 46 return "", errors.New("ClientName is required") 47 } 48 if opts.SessionID == "" { 49 return "", errors.New("SessionID is required") 50 } 51 if opts.RelaySAS == "" { 52 return "", errors.New("RelaySAS is required") 53 } 54 if opts.RelayEndpoint == "" { 55 return "", errors.New("RelayEndpoint is required") 56 } 57 58 sas := url.QueryEscape(opts.RelaySAS) 59 uri := opts.RelayEndpoint 60 uri = strings.Replace(uri, "sb:", "wss:", -1) 61 uri = strings.Replace(uri, ".net/", ".net:443/$hc/", 1) 62 uri = uri + "?sb-hc-action=" + action + "&sb-hc-token=" + sas 63 return uri, nil 64} 65 66// Connect connects to a Live Share workspace specified by the 67// options, and returns a session representing the connection. 68// The caller must call the session's Close method to end the session. 69func Connect(ctx context.Context, opts Options) (*Session, error) { 70 span, ctx := opentracing.StartSpanFromContext(ctx, "Connect") 71 defer span.Finish() 72 73 uri, err := opts.uri("connect") 74 if err != nil { 75 return nil, err 76 } 77 78 sock := newSocket(uri, opts.TLSConfig) 79 if err := sock.connect(ctx); err != nil { 80 return nil, fmt.Errorf("error connecting websocket: %w", err) 81 } 82 83 if opts.SessionToken == "" { 84 return nil, errors.New("SessionToken is required") 85 } 86 ssh := newSSHSession(opts.SessionToken, opts.HostPublicKeys, sock) 87 if err := ssh.connect(ctx); err != nil { 88 return nil, fmt.Errorf("error connecting to ssh session: %w", err) 89 } 90 91 rpc := newRPCClient(ssh) 92 rpc.connect(ctx) 93 94 args := joinWorkspaceArgs{ 95 ID: opts.SessionID, 96 ConnectionMode: "local", 97 JoiningUserSessionToken: opts.SessionToken, 98 ClientCapabilities: clientCapabilities{ 99 IsNonInteractive: false, 100 }, 101 } 102 var result joinWorkspaceResult 103 if err := rpc.do(ctx, "workspace.joinWorkspace", &args, &result); err != nil { 104 return nil, fmt.Errorf("error joining Live Share workspace: %w", err) 105 } 106 107 s := &Session{ 108 ssh: ssh, 109 rpc: rpc, 110 clientName: opts.ClientName, 111 keepAliveReason: make(chan string, 1), 112 logger: opts.Logger, 113 } 114 go s.heartbeat(ctx, 1*time.Minute) 115 116 return s, nil 117} 118 119type clientCapabilities struct { 120 IsNonInteractive bool `json:"isNonInteractive"` 121} 122 123type joinWorkspaceArgs struct { 124 ID string `json:"id"` 125 ConnectionMode string `json:"connectionMode"` 126 JoiningUserSessionToken string `json:"joiningUserSessionToken"` 127 ClientCapabilities clientCapabilities `json:"clientCapabilities"` 128} 129 130type joinWorkspaceResult struct { 131 SessionNumber int `json:"sessionNumber"` 132} 133 134// A channelID is an identifier for an exposed port on a remote 135// container that may be used to open an SSH channel to it. 136type channelID struct { 137 name, condition string 138} 139 140func (s *Session) openStreamingChannel(ctx context.Context, id channelID) (ssh.Channel, error) { 141 type getStreamArgs struct { 142 StreamName string `json:"streamName"` 143 Condition string `json:"condition"` 144 } 145 args := getStreamArgs{ 146 StreamName: id.name, 147 Condition: id.condition, 148 } 149 var streamID string 150 if err := s.rpc.do(ctx, "streamManager.getStream", args, &streamID); err != nil { 151 return nil, fmt.Errorf("error getting stream id: %w", err) 152 } 153 154 span, ctx := opentracing.StartSpanFromContext(ctx, "Session.OpenChannel+SendRequest") 155 defer span.Finish() 156 _ = ctx // ctx is not currently used 157 158 channel, reqs, err := s.ssh.conn.OpenChannel("session", nil) 159 if err != nil { 160 return nil, fmt.Errorf("error opening ssh channel for transport: %w", err) 161 } 162 go ssh.DiscardRequests(reqs) 163 164 requestType := fmt.Sprintf("stream-transport-%s", streamID) 165 if _, err = channel.SendRequest(requestType, true, nil); err != nil { 166 return nil, fmt.Errorf("error sending channel request: %w", err) 167 } 168 169 return channel, nil 170} 171