From 7994f50c814dbb36c55561898709bfdacf06c82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Buli=C5=84ski?= Date: Thu, 1 Feb 2024 10:33:48 +0100 Subject: [PATCH] DEV-1491: Remote access v2 (#20) --- client.go | 2 +- connect.go | 128 +++++++++++++++++++++++++++++++++++++++++- device.go | 51 +++++++++++++++++ go.mod | 12 ++-- go.sum | 30 ++++++---- remote_access.go | 6 -- remote_access_test.go | 5 -- 7 files changed, 203 insertions(+), 31 deletions(-) create mode 100644 device.go diff --git a/client.go b/client.go index 52f2a15..3cad8a0 100644 --- a/client.go +++ b/client.go @@ -57,7 +57,7 @@ func (cli *Client) GetHTTPClient() *http.Client { // WithBaseURL sets the base URL of the API endpoint. func (cli *Client) WithBaseURL(baseURL string) *Client { - cli.baseURL = baseURL + cli.baseURL = strings.TrimSuffix(baseURL, "/") return cli } diff --git a/connect.go b/connect.go index 246bcb1..9d31932 100644 --- a/connect.go +++ b/connect.go @@ -18,13 +18,20 @@ package client import ( "context" + "crypto/tls" "encoding/json" "fmt" + "io" + "net" "net/http" "net/url" + "os" + "strings" "sync" "time" + "go.qbee.io/transport" + chisel "github.com/jpillora/chisel/client" "golang.org/x/net/http/httpproxy" ) @@ -220,10 +227,106 @@ func (cli *Client) ParseConnect(ctx context.Context, deviceID string, targets [] return cli.Connect(ctx, deviceID, parsedTargets) } -// Connect establishes a connection to a remote device. -func (cli *Client) Connect(ctx context.Context, deviceID string, targets []RemoteAccessTarget) error { +// connectStdio connects to the given target using stdin/stdout. +func (cli *Client) connectStdio(ctx context.Context, client *transport.Client, target RemoteAccessTarget) error { + remoteHostPort := fmt.Sprintf("%s:%s", target.RemoteHost, target.RemotePort) + + stream, err := client.OpenStream(ctx, transport.MessageTypeTCPTunnel, []byte(remoteHostPort)) + if err != nil { + return fmt.Errorf("error opening stream: %w", err) + } + defer stream.Close() + + // copy from stdin to stream + go func() { + _, _ = io.Copy(stream, os.Stdin) + }() + + // copy from stream to stdout + _, err = io.Copy(os.Stdout, stream) + + return err +} + +// connect establishes a connection to a remote device. +func (cli *Client) connect(ctx context.Context, deviceUUID, edgeHost string, targets []RemoteAccessTarget) error { + edgeURL := fmt.Sprintf("https://%s/device/%s", edgeHost, deviceUUID) + + var tlsConfig *tls.Config + + // for testing purposes, allow connections to localhost without verifying the certificate + if strings.HasPrefix(edgeHost, "edge:") || strings.HasPrefix(edgeHost, "localhost:") { + tlsConfig = &tls.Config{ + InsecureSkipVerify: true, + } + } + + if len(targets) == 0 { + return fmt.Errorf("no targets defined") + } + + client, err := transport.NewClient(ctx, edgeURL, cli.authToken, tlsConfig) + if err != nil { + return fmt.Errorf("error initializing remote access client: %w", err) + } + + // close the client and all local listeners when the context is cancelled + closers := []io.Closer{client} + defer func() { + for _, closer := range closers { + _ = closer.Close() + } + }() + + if len(targets) == 1 && targets[0].LocalPort == "stdio" { + return cli.connectStdio(ctx, client, targets[0]) + } + + for _, target := range targets { + if target.LocalPort == "stdio" { + return fmt.Errorf("stdio is only supported for single target connections") + } + + localHostPort := fmt.Sprintf("localhost:%s", target.LocalPort) + remoteHostPort := fmt.Sprintf("%s:%s", target.RemoteHost, target.RemotePort) + + switch target.Protocol { + case "tcp": + var tcpListener *net.TCPListener + if tcpListener, err = client.OpenTCPTunnel(ctx, localHostPort, remoteHostPort); err != nil { + return fmt.Errorf("error opening TCP tunnel: %w", err) + } + + closers = append(closers, tcpListener) + case "udp": + var udpConn *transport.UDPTunnel + if udpConn, err = client.OpenUDPTunnel(ctx, localHostPort, remoteHostPort); err != nil { + return fmt.Errorf("error opening UDP tunnel: %w", err) + } + + closers = append(closers, udpConn) + default: + return fmt.Errorf("invalid protocol %s", target.Protocol) + } + + fmt.Printf("Tunneling %s %s to %s\n", target.Protocol, localHostPort, remoteHostPort) + } + + // Wait for context to be cancelled + <-ctx.Done() + + return nil +} + +// legacyConnect establishes a connection to a remote device using the legacy remote access solution. +func (cli *Client) legacyConnect(ctx context.Context, deviceID string, targets []RemoteAccessTarget) error { ports := make([]string, len(targets)) for _, target := range targets { + // only localhost is supported as remote host for legacy remote access + if target.RemoteHost != "localhost" { + return fmt.Errorf("invalid remote host: only localhost is supported") + } + ports = append(ports, fmt.Sprintf("%s:%s", target.Protocol, target.RemotePort)) } @@ -281,3 +384,24 @@ func (cli *Client) Connect(ctx context.Context, deviceID string, targets []Remot } return nil } + +// Connect establishes a connection to a remote device. +func (cli *Client) Connect(ctx context.Context, deviceID string, targets []RemoteAccessTarget) error { + deviceStatus, err := cli.GetDeviceStatus(ctx, deviceID) + if err != nil { + return err + } + + if !deviceStatus.RemoteAccess { + return fmt.Errorf("remote access is not available for device %s", deviceID) + } + + switch deviceStatus.EdgeVersion { + case EdgeVersionOpenVPN: + return cli.legacyConnect(ctx, deviceID, targets) + case EdgeVersionNative: + return cli.connect(ctx, deviceStatus.UUID, deviceStatus.Edge, targets) + default: + return fmt.Errorf("unsupported edge version %d", deviceStatus.EdgeVersion) + } +} diff --git a/device.go b/device.go new file mode 100644 index 0000000..ab86e7a --- /dev/null +++ b/device.go @@ -0,0 +1,51 @@ +package client + +import ( + "context" + "fmt" + "net/http" +) + +// EdgeVersion indicates the version of the edge server that the device is connected to. +type EdgeVersion uint8 + +const ( + // EdgeVersionOpenVPN indicates that the device is connected to an OpenVPN edge. + EdgeVersionOpenVPN = 0 + + // EdgeVersionNative indicates that the device is connected to a native qbee remote access edge. + EdgeVersionNative = 1 +) + +// DeviceStatus is the status of a device. +type DeviceStatus struct { + // UUID is the UUID of the device. + UUID string `json:"uuid"` + + // RemoteAccess is true if the device is connected to the edge. + RemoteAccess bool `json:"remote_access"` + + // Edge is the edge host that the device is connected to. + // This field is only set if RemoteAccess is true. + // Format is :/edge/ + Edge string `json:"edge,omitempty"` + + // EdgeVersion is the version of the edge that the device is connected to. + // This field is only set if RemoteAccess is true. + // 0 - for OpenVPN edge + // 1 - for native qbee remote access + EdgeVersion EdgeVersion `json:"edge_version,omitempty"` +} + +// GetDeviceStatus returns device status. +func (cli *Client) GetDeviceStatus(ctx context.Context, deviceID string) (*DeviceStatus, error) { + deviceStatus := new(DeviceStatus) + + path := fmt.Sprintf("/api/v2/device/%s/status", deviceID) + + if err := cli.Call(ctx, http.MethodGet, path, nil, deviceStatus); err != nil { + return nil, err + } + + return deviceStatus, nil +} diff --git a/go.mod b/go.mod index 0069aa3..eadfc87 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,19 @@ go 1.21 require ( github.com/jpillora/chisel v1.9.1 - golang.org/x/net v0.17.0 + go.qbee.io/transport v1.24.7 + golang.org/x/net v0.20.0 ) require ( github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/gorilla/websocket v1.5.0 // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/jpillora/sizestr v1.0.0 // indirect - golang.org/x/crypto v0.17.0 // indirect - golang.org/x/sync v0.4.0 // indirect - golang.org/x/sys v0.15.0 // indirect + github.com/xtaci/smux v1.5.24 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index f95657d..6ada729 100644 --- a/go.sum +++ b/go.sum @@ -2,23 +2,29 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jpillora/chisel v1.9.1 h1:nGOF58+45WHlvDcq6AZu7En8nWOBCZHqj9boo5rB4qU= github.com/jpillora/chisel v1.9.1/go.mod h1:qvgGfFR9ZhiDoYJM4IM1omX1HLbQSkZag8miP9u4SsQ= github.com/jpillora/sizestr v1.0.0 h1:4tr0FLxs1Mtq3TnsLDV+GYUWG7Q26a6s+tV5Zfw2ygw= github.com/jpillora/sizestr v1.0.0/go.mod h1:bUhLv4ctkknatr6gR42qPxirmd5+ds1u7mzD+MZ33f0= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= -golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +github.com/xtaci/smux v1.5.24 h1:77emW9dtnOxxOQ5ltR+8BbsX1kzcOxQ5gB+aaV9hXOY= +github.com/xtaci/smux v1.5.24/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= +go.qbee.io/transport v1.24.6 h1:rMg6Zg2HPuFgmOLzZ9678Fx7OV+46/e+bR0WBENR0tE= +go.qbee.io/transport v1.24.6/go.mod h1:RSuaDe4RBsxEpXd9PiVKuo85bJd/G0bhK2I3j2fheC8= +go.qbee.io/transport v1.24.7 h1:pUp2nrMrvZy3CkYX6wRD0J5+qK4CluUzgyR+6vrvvlE= +go.qbee.io/transport v1.24.7/go.mod h1:RSuaDe4RBsxEpXd9PiVKuo85bJd/G0bhK2I3j2fheC8= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/remote_access.go b/remote_access.go index 4a1ec91..ea2e363 100644 --- a/remote_access.go +++ b/remote_access.go @@ -91,12 +91,6 @@ func ParseRemoteAccessTarget(targetString string) (RemoteAccessTarget, error) { target.RemoteHost = parts[1] - // Only localhost is supported as remote host at the moment. - // Once we roll out the new remote access solution, will allow any host in the same network. - if target.RemoteHost != "localhost" { - return target, fmt.Errorf("invalid remote host: only localhost is supported") - } - remotePort := parts[2] if strings.HasSuffix(remotePort, "/udp") { target.Protocol = UDP diff --git a/remote_access_test.go b/remote_access_test.go index ef63af4..5e37729 100644 --- a/remote_access_test.go +++ b/remote_access_test.go @@ -76,11 +76,6 @@ func TestParseRemoteAccessTarget(t *testing.T) { targetString: "localhost", wantErr: "invalid format", }, - { - name: "unsupported host", - targetString: "123:example.com:123", - wantErr: "invalid remote host: only localhost is supported", - }, { name: "local port out of range", targetString: "123456:localhost:2",