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