initial commit

This commit is contained in:
Jose Garcia 2021-06-23 20:00:24 -04:00
commit 8eba57a9ed
7 changed files with 386 additions and 0 deletions

130
api.go Normal file
View file

@ -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
}

28
client.go Normal file
View file

@ -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
}

56
config.go Normal file
View file

@ -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
}

23
example/main.go Normal file
View file

@ -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))
}
}

35
liveshare.go Normal file
View file

@ -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
}

44
session.go Normal file
View file

@ -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
}

70
ssh.go Normal file
View file

@ -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
}