From 8eba57a9ed6ccf69c4944939cd587ff3e1403e70 Mon Sep 17 00:00:00 2001 From: Jose Garcia Date: Wed, 23 Jun 2021 20:00:24 -0400 Subject: [PATCH] initial commit --- api.go | 130 ++++++++++++++++++++++++++++++++++++++++++++++++ client.go | 28 +++++++++++ config.go | 56 +++++++++++++++++++++ example/main.go | 23 +++++++++ liveshare.go | 35 +++++++++++++ session.go | 44 ++++++++++++++++ ssh.go | 70 ++++++++++++++++++++++++++ 7 files changed, 386 insertions(+) create mode 100644 api.go create mode 100644 client.go create mode 100644 config.go create mode 100644 example/main.go create mode 100644 liveshare.go create mode 100644 session.go create mode 100644 ssh.go diff --git a/api.go b/api.go new file mode 100644 index 000000000..c7efb9830 --- /dev/null +++ b/api.go @@ -0,0 +1,130 @@ +package liveshare + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" +) + +type API struct { + Configuration *Configuration + HttpClient *http.Client + ServiceURI string + WorkspaceID string +} + +func NewAPI(configuration *Configuration) *API { + serviceURI := configuration.LiveShareEndpoint + if !strings.HasSuffix(configuration.LiveShareEndpoint, "/") { + serviceURI = configuration.LiveShareEndpoint + "/" + } + + if !strings.Contains(serviceURI, "api/v1.2") { + serviceURI = serviceURI + "api/v1.2" + } + + serviceURI = strings.TrimSuffix(serviceURI, "/") + + return &API{configuration, &http.Client{}, serviceURI, strings.ToUpper(configuration.WorkspaceID)} +} + +type WorkspaceAccessResponse struct { + SessionToken string `json:"sessionToken"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Name string `json:"name"` + OwnerID string `json:"ownerId"` + JoinLink string `json:"joinLink"` + ConnectLinks []string `json:"connectLinks"` + RelayLink string `json:"relayLink"` + RelaySas string `json:"relaySas"` + HostPublicKeys []string `json:"hostPublicKeys"` + ConversationID string `json:"conversationId"` + AssociatedUserIDs []string `json:"associatedUserIds"` + AreAnonymousGuestsAllowed bool `json:"areAnonymousGuestsAllowed"` + IsHostConnected bool `json:"isHostConnected"` + ExpiresAt string `json:"expiresAt"` + InvitationLinks []string `json:"invitationLinks"` + ID string `json:"id"` +} + +func (a *API) WorkspaceAccess() (*WorkspaceAccessResponse, error) { + url := fmt.Sprintf("%s/workspace/%s/user", a.ServiceURI, a.WorkspaceID) + + req, err := http.NewRequest(http.MethodPut, url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + a.setDefaultHeaders(req) + resp, err := a.HttpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %v", err) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + var workspaceAccessResponse WorkspaceAccessResponse + if err := json.Unmarshal(b, &workspaceAccessResponse); err != nil { + return nil, fmt.Errorf("error unmarshaling response into json: %v", err) + } + + return &workspaceAccessResponse, nil +} + +func (a *API) setDefaultHeaders(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+a.Configuration.Token) + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Content-Type", "application/json") +} + +type WorkspaceInfoResponse struct { + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Name string `json:"name"` + OwnerID string `json:"ownerId"` + JoinLink string `json:"joinLink"` + ConnectLinks []string `json:"connectLinks"` + RelayLink string `json:"relayLink"` + RelaySas string `json:"relaySas"` + HostPublicKeys []string `json:"hostPublicKeys"` + ConversationID string `json:"conversationId"` + AssociatedUserIDs []string `json:"associatedUserIds"` + AreAnonymousGuestsAllowed bool `json:"areAnonymousGuestsAllowed"` + IsHostConnected bool `json:"isHostConnected"` + ExpiresAt string `json:"expiresAt"` + InvitationLinks []string `json:"invitationLinks"` + ID string `json:"id"` +} + +func (a *API) WorkspaceInfo() (*WorkspaceInfoResponse, error) { + url := fmt.Sprintf("%s/workspace/%s", a.ServiceURI, a.WorkspaceID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + a.setDefaultHeaders(req) + resp, err := a.HttpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %v", err) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + var workspaceInfoResponse WorkspaceInfoResponse + if err := json.Unmarshal(b, &workspaceInfoResponse); err != nil { + return nil, fmt.Errorf("error unmarshaling response into json: %v", err) + } + + return &workspaceInfoResponse, nil +} diff --git a/client.go b/client.go new file mode 100644 index 000000000..a58a34c9b --- /dev/null +++ b/client.go @@ -0,0 +1,28 @@ +package liveshare + +import ( + "context" + "fmt" +) + +type Client struct { + Configuration *Configuration +} + +func NewClient(configuration *Configuration) *Client { + return &Client{configuration} +} + +func (c *Client) Join(ctx context.Context) error { + session, err := GetSession(ctx, c.Configuration) + if err != nil { + return fmt.Errorf("error getting session: %v", err) + } + + sshSession := NewSSHSession(session) + if err := sshSession.Connect(); err != nil { + return fmt.Errorf("error authenticating ssh session: %v", err) + } + + return nil +} diff --git a/config.go b/config.go new file mode 100644 index 000000000..74eb5b178 --- /dev/null +++ b/config.go @@ -0,0 +1,56 @@ +package liveshare + +import ( + "errors" + "strings" +) + +type Option func(configuration *Configuration) error + +func WithWorkspaceID(id string) Option { + return func(configuration *Configuration) error { + configuration.WorkspaceID = id + return nil + } +} + +func WithLiveShareEndpoint(liveShareEndpoint string) Option { + return func(configuration *Configuration) error { + configuration.LiveShareEndpoint = liveShareEndpoint + return nil + } +} + +func WithToken(token string) Option { + return func(configuration *Configuration) error { + configuration.Token = token + return nil + } +} + +type Configuration struct { + WorkspaceID, LiveShareEndpoint, Token string +} + +func NewConfiguration() *Configuration { + return &Configuration{ + LiveShareEndpoint: "https://prod.liveshare.vsengsaas.visualstudio.com", + } +} + +func (c *Configuration) Validate() error { + errs := []string{} + if c.WorkspaceID == "" { + errs = append(errs, "WorkspaceID is required") + } + + if c.Token == "" { + errs = append(errs, "Token is required") + } + + if len(errs) > 0 { + return errors.New(strings.Join(errs, ", ")) + } + + return nil +} diff --git a/example/main.go b/example/main.go new file mode 100644 index 000000000..79ab53377 --- /dev/null +++ b/example/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "fmt" + "log" + + "github.com/josebalius/go-liveshare" +) + +func main() { + liveShare, err := liveshare.New( + liveshare.WithWorkspaceID("..."), + liveshare.WithToken("..."), + ) + if err != nil { + log.Fatal(fmt.Errorf("error creating liveshare: %v", err)) + } + + if err := liveShare.Connect(context.Background()); err != nil { + log.Fatal(fmt.Errorf("error connecting to liveshare: %v", err)) + } +} diff --git a/liveshare.go b/liveshare.go new file mode 100644 index 000000000..a8c8b69d6 --- /dev/null +++ b/liveshare.go @@ -0,0 +1,35 @@ +package liveshare + +import ( + "context" + "fmt" +) + +type LiveShare struct { + Configuration *Configuration +} + +func New(opts ...Option) (*LiveShare, error) { + configuration := NewConfiguration() + + for _, o := range opts { + if err := o(configuration); err != nil { + return nil, fmt.Errorf("error configuring liveshare: %v", err) + } + } + + if err := configuration.Validate(); err != nil { + return nil, fmt.Errorf("error validating configuration: %v", err) + } + + return &LiveShare{configuration}, nil +} + +func (l *LiveShare) Connect(ctx context.Context) error { + workspaceClient := NewClient(l.Configuration) + if err := workspaceClient.Join(ctx); err != nil { + return fmt.Errorf("error joining with workspace client: %v", err) + } + + return nil +} diff --git a/session.go b/session.go new file mode 100644 index 000000000..24a284ef2 --- /dev/null +++ b/session.go @@ -0,0 +1,44 @@ +package liveshare + +import ( + "context" + "fmt" + + "golang.org/x/sync/errgroup" +) + +type Session struct { + WorkspaceAccess *WorkspaceAccessResponse + WorkspaceInfo *WorkspaceInfoResponse +} + +func GetSession(ctx context.Context, configuration *Configuration) (*Session, error) { + api := NewAPI(configuration) + session := new(Session) + + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + workspaceAccess, err := api.WorkspaceAccess() + if err != nil { + return fmt.Errorf("error getting workspace access: %v", err) + } + session.WorkspaceAccess = workspaceAccess + return nil + }) + + g.Go(func() error { + workspaceInfo, err := api.WorkspaceInfo() + if err != nil { + return fmt.Errorf("error getting workspace info: %v", err) + } + session.WorkspaceInfo = workspaceInfo + return nil + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + return session, nil +} diff --git a/ssh.go b/ssh.go new file mode 100644 index 000000000..af132b331 --- /dev/null +++ b/ssh.go @@ -0,0 +1,70 @@ +package liveshare + +import ( + "fmt" + "net" + "net/url" + "strings" + + "golang.org/x/crypto/ssh" + "golang.org/x/net/websocket" +) + +type SSHSession struct { + Session *Session + VersionExchangeError chan error +} + +func NewSSHSession(session *Session) *SSHSession { + return &SSHSession{ + Session: session, + } +} + +func (s *SSHSession) Connect() error { + socketStream, err := s.socketStream() + if err != nil { + return fmt.Errorf("error creating socket stream: %v", err) + } + + clientConfig := ssh.ClientConfig{ + User: "", + Auth: []ssh.AuthMethod{ + ssh.Password(s.Session.WorkspaceAccess.SessionToken), + }, + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + // TODO(josebalius): implement + return nil + }, + } + + sshClientConn, chans, reqs, err := ssh.NewClientConn(socketStream, "", &clientConfig) + if err != nil { + return fmt.Errorf("error creating ssh client connection: %v", err) + } + + fmt.Println(sshClientConn, chans, reqs) + + return nil +} + +// Reference: +// https://github.com/Azure/azure-relay-node/blob/7b57225365df3010163bf4b9e640868a02737eb6/hyco-ws/index.js#L107-L137 +func (s *SSHSession) relayURI(action string) string { + relaySas := url.QueryEscape(s.Session.WorkspaceAccess.RelaySas) + relayURI := s.Session.WorkspaceAccess.RelayLink + relayURI = strings.Replace(relayURI, "sb:", "wss:", -1) + relayURI = strings.Replace(relayURI, ".net/", ".net:443/$hc/", 1) + relayURI = relayURI + "?sb-hc-action=" + action + "&sb-hc-token=" + relaySas + return relayURI +} + +func (s *SSHSession) socketStream() (*websocket.Conn, error) { + uri := s.relayURI("connect") + ws, err := websocket.Dial(uri, "", uri) + if err != nil { + return nil, fmt.Errorf("error dialing relay connection: %v", err) + } + + return ws, nil +}