initial commit
This commit is contained in:
commit
8eba57a9ed
7 changed files with 386 additions and 0 deletions
130
api.go
Normal file
130
api.go
Normal 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
28
client.go
Normal 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
56
config.go
Normal 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
23
example/main.go
Normal 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
35
liveshare.go
Normal 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
44
session.go
Normal 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
70
ssh.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue