Skip to content

Commit 5c1d940

Browse files
committed
add websocket
1 parent bf81716 commit 5c1d940

File tree

10 files changed

+332
-7
lines changed

10 files changed

+332
-7
lines changed

api/node_type.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,27 @@ type Node struct {
1515
Type string `json:"type"`
1616
UpTime int `json:"uptime"`
1717
}
18+
19+
type TermProxy struct {
20+
Port string `json:"port"`
21+
Ticket string `json:"ticket"`
22+
UPID string `json:"upid"`
23+
User string `json:"user"`
24+
}
25+
26+
type TermProxyOption struct {
27+
CMD string `json:"cmd,omitempty"`
28+
CMDOpts string `json:"cmd-opts,omitempty"`
29+
}
30+
31+
type VNCShellOption struct {
32+
TermProxyOption
33+
Height int `json:"height,omitempty"`
34+
Websocket bool `json:"websocket,omitempty"`
35+
Width int `json:"width,omitempty"`
36+
}
37+
38+
type VNCWebSocket struct {
39+
Port string `json:"port,omitempty"`
40+
VNCTicket string `json:"vncticket,omitempty"`
41+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/sp-yduck/proxmox-go
33
go 1.20
44

55
require (
6+
github.com/gorilla/websocket v1.5.0
67
github.com/pkg/errors v0.9.1
78
github.com/stretchr/testify v1.8.4
89
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
22
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
4+
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
35
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
46
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
57
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

proxmox/websocket.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package proxmox
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"regexp"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/pkg/errors"
11+
12+
"github.com/gorilla/websocket"
13+
"github.com/sp-yduck/proxmox-go/api"
14+
)
15+
16+
const (
17+
finMessage = "done with status: "
18+
finMessageFormat = finMessage + `[0-9]+`
19+
)
20+
21+
type VNCWebSocketClient struct {
22+
conn *websocket.Conn
23+
}
24+
25+
func (s *Service) NewNodeVNCWebSocketConnection(ctx context.Context, nodeName string) (*VNCWebSocketClient, error) {
26+
termProxy, err := s.restclient.CreateNodeTermProxy(ctx, nodeName, api.TermProxyOption{})
27+
if err != nil {
28+
return nil, err
29+
}
30+
conn, err := s.restclient.DialNodeVNCWebSocket(ctx, nodeName, *termProxy)
31+
if err != nil {
32+
return nil, err
33+
}
34+
35+
return &VNCWebSocketClient{conn: conn}, nil
36+
}
37+
38+
func (c *VNCWebSocketClient) Close() {
39+
c.conn.Close()
40+
}
41+
42+
func (c *VNCWebSocketClient) Write(cmd string) error {
43+
b := []byte(fmt.Sprintf("%s\n", cmd))
44+
bheader := []byte(fmt.Sprintf("0:%d:", len(b)))
45+
bmsg := append(bheader, b...)
46+
if err := c.conn.WriteMessage(websocket.BinaryMessage, bmsg); err != nil {
47+
return err
48+
}
49+
return c.sendFinMessage()
50+
}
51+
52+
func (c *VNCWebSocketClient) sendFinMessage() error {
53+
b := []byte(fmt.Sprintf(`echo "%s$?"%s`, finMessage, "\n"))
54+
bheader := []byte(fmt.Sprintf("0:%d:", len(b)))
55+
bmsg := append(bheader, b...)
56+
if err := c.conn.WriteMessage(websocket.BinaryMessage, bmsg); err != nil {
57+
return err
58+
}
59+
return nil
60+
}
61+
62+
// Read() reads message until find fin message
63+
// then returns whole message and status code
64+
func (c *VNCWebSocketClient) Read(ctx context.Context) (outputs string, code int, err error) {
65+
done := make(chan error, 1)
66+
go func() {
67+
defer close(done)
68+
for {
69+
_, msg, err := c.conn.ReadMessage()
70+
if err != nil {
71+
done <- err
72+
return
73+
}
74+
outputs += string(msg)
75+
finMsg := parseFinMessage(string(msg))
76+
if finMsg != "" {
77+
code, err = parseStatusFromFinMessage(finMsg)
78+
done <- err
79+
return
80+
}
81+
}
82+
}()
83+
select {
84+
case err = <-done:
85+
return outputs, code, err
86+
case <-ctx.Done():
87+
return outputs, -1, errors.New("context deadline exceeded")
88+
}
89+
}
90+
91+
// Exec executes a command and return error if code is not 0
92+
// usually out contains many extra messages that is just useless
93+
func (c *VNCWebSocketClient) Exec(ctx context.Context, cmd string) (out string, code int, err error) {
94+
if err := c.Write(cmd); err != nil {
95+
return "", 0, err
96+
}
97+
out, code, err = c.Read(ctx)
98+
if err != nil {
99+
return out, code, err
100+
}
101+
if code != 0 {
102+
return out, code, errors.Errorf("exit with non zero code: %d", code)
103+
}
104+
return out, 0, nil
105+
}
106+
107+
func parseFinMessage(message string) string {
108+
re := regexp.MustCompile(finMessageFormat)
109+
return re.FindString(message)
110+
}
111+
112+
func parseStatusFromFinMessage(message string) (int, error) {
113+
re := regexp.MustCompile(finMessageFormat)
114+
match := re.FindString(message)
115+
if match == "" {
116+
return 0, errors.Errorf("failed to find status code from %s", message)
117+
}
118+
statusCode := strings.Split(match, ": ")[1]
119+
return strconv.Atoi(statusCode)
120+
}

proxmox/websocket_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package proxmox
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
func (s *TestSuite) TestVNCWebSocketClient() {
10+
testNode := s.getTestNode()
11+
client, err := s.service.NewNodeVNCWebSocketConnection(context.TODO(), testNode.Node)
12+
if err != nil {
13+
s.T().Fatalf("failed to create new vnc client: %v", err)
14+
}
15+
defer client.Close()
16+
17+
if err := client.Write("pwd"); err != nil {
18+
s.T().Fatalf("write error: %v", err)
19+
}
20+
21+
ctx, _ := context.WithTimeout(context.TODO(), 10*time.Second)
22+
out, _, err := client.Read(ctx)
23+
if err != nil {
24+
s.T().Fatalf("failed read message: %v", err)
25+
}
26+
27+
s.T().Logf("read message: %s", out)
28+
}
29+
30+
func (s *TestSuite) TestExec() {
31+
testNode := s.getTestNode()
32+
client, err := s.service.NewNodeVNCWebSocketConnection(context.TODO(), testNode.Node)
33+
if err != nil {
34+
s.T().Fatalf("failed to create new vnc client: %v", err)
35+
}
36+
defer client.Close()
37+
38+
ctx, _ := context.WithTimeout(context.TODO(), 5*time.Second)
39+
out, code, err := client.Exec(ctx, "whoami | base64 | base64 -d")
40+
if err != nil {
41+
s.T().Fatalf("failed to exec command: %s : %d : %v", out, code, err)
42+
}
43+
s.T().Logf("exec command : %s : %d", out, code)
44+
}
45+
46+
func TestParseFinMessage(t *testing.T) {
47+
testMsg := " daf" + finMessage + "123\n"
48+
if parseFinMessage(testMsg) == "" {
49+
t.Fatalf("wrong")
50+
}
51+
}

rest/client.go

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"io"
99
"io/ioutil"
1010
"net/http"
11-
"net/url"
1211
"strings"
1312
"time"
1413

@@ -62,6 +61,7 @@ func complementURL(url string) string {
6261
if !strings.HasPrefix(url, "http") {
6362
url = "http://" + url
6463
}
64+
url, _ = strings.CutSuffix(url, "/")
6565
return url
6666
}
6767

@@ -96,10 +96,7 @@ func WithAPIToken(tokenid, secret string) ClientOption {
9696
}
9797

9898
func (c *RESTClient) Do(ctx context.Context, httpMethod, urlPath string, req, v interface{}) error {
99-
url, err := url.JoinPath(c.endpoint, urlPath)
100-
if err != nil {
101-
return err
102-
}
99+
endpoint := c.endpoint + urlPath
103100

104101
var body io.Reader
105102
if req != nil {
@@ -110,7 +107,7 @@ func (c *RESTClient) Do(ctx context.Context, httpMethod, urlPath string, req, v
110107
body = bytes.NewReader(jsonReq)
111108
}
112109

113-
httpReq, err := http.NewRequestWithContext(ctx, httpMethod, url, body)
110+
httpReq, err := http.NewRequestWithContext(ctx, httpMethod, endpoint, body)
114111
if err != nil {
115112
return err
116113
}
@@ -162,7 +159,6 @@ func (c *RESTClient) Delete(ctx context.Context, path string, req, res interface
162159

163160
func (c *RESTClient) makeAuthHeaders() http.Header {
164161
header := make(http.Header)
165-
// header.Add("User-Agent", c.userAgent)
166162
header.Add("Accept", "application/json")
167163
if c.token != "" {
168164
header.Add("Authorization", fmt.Sprintf("PVEAPIToken=%s", c.token))

rest/node.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package rest
22

33
import (
44
"context"
5+
"fmt"
6+
"net/url"
57

68
"github.com/sp-yduck/proxmox-go/api"
79
)
@@ -26,3 +28,30 @@ func (c *RESTClient) GetNode(ctx context.Context, name string) (*api.Node, error
2628
}
2729
return nil, NotFoundErr
2830
}
31+
32+
func (c *RESTClient) CreateNodeTermProxy(ctx context.Context, nodeName string, option api.TermProxyOption) (*api.TermProxy, error) {
33+
path := fmt.Sprintf("/nodes/%s/termproxy", nodeName)
34+
var termProxy *api.TermProxy
35+
if err := c.Post(ctx, path, option, &termProxy); err != nil {
36+
return nil, err
37+
}
38+
return termProxy, nil
39+
}
40+
41+
func (c *RESTClient) CreateNodeVNCShell(ctx context.Context, nodeName string, option api.VNCShellOption) (*api.TermProxy, error) {
42+
path := fmt.Sprintf("/nodes/%s/vncshell", nodeName)
43+
var termProxy *api.TermProxy
44+
if err := c.Post(ctx, path, option, &termProxy); err != nil {
45+
return nil, err
46+
}
47+
return termProxy, nil
48+
}
49+
50+
func (c *RESTClient) GetNodeVNCWebSocket(ctx context.Context, nodeName, port, vncticket string) (*api.VNCWebSocket, error) {
51+
path := fmt.Sprintf("/nodes/%s/vncwebsocket?port=%s&vncticket=%s", nodeName, port, url.QueryEscape(vncticket))
52+
var websocket *api.VNCWebSocket
53+
if err := c.Get(ctx, path, &websocket); err != nil {
54+
return nil, err
55+
}
56+
return websocket, nil
57+
}

rest/nodes_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package rest
2+
3+
import (
4+
"context"
5+
6+
"github.com/sp-yduck/proxmox-go/api"
7+
)
8+
9+
func (s *TestSuite) TestCreateTermProxy() {
10+
testNode := s.GetTestNode()
11+
termProxy, err := s.restclient.CreateNodeTermProxy(context.TODO(), testNode.Node, api.TermProxyOption{})
12+
if err != nil {
13+
s.T().Fatalf("failed to create termproxy: %v", err)
14+
}
15+
s.T().Logf("create termproxy: %v", termProxy)
16+
}
17+
18+
func (s *TestSuite) TestGetVNCWebSocket() {
19+
testNode := s.GetTestNode()
20+
termProxy, err := s.restclient.CreateNodeTermProxy(context.TODO(), testNode.Node, api.TermProxyOption{})
21+
if err != nil {
22+
s.T().Fatalf("failed to create termproxy: %v", err)
23+
}
24+
s.T().Logf("create termproxy: %v", termProxy)
25+
26+
websocket, err := s.restclient.GetNodeVNCWebSocket(context.TODO(), testNode.Node, termProxy.Port, termProxy.Ticket)
27+
if err != nil {
28+
s.T().Fatalf("failed to get vncwebsocket: %v", err)
29+
}
30+
s.T().Logf("get vncwebsocket: %v", websocket)
31+
}

rest/websocket.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package rest
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
"time"
11+
12+
"github.com/gorilla/websocket"
13+
"github.com/pkg/errors"
14+
15+
"github.com/sp-yduck/proxmox-go/api"
16+
)
17+
18+
func (c *RESTClient) DialNodeVNCWebSocket(ctx context.Context, nodeName string, vnc api.TermProxy) (*websocket.Conn, error) {
19+
baseUrl := strings.Replace(c.endpoint, "https://", "wss://", 1)
20+
baseUrl = strings.Replace(baseUrl, "http://", "wss://", 1)
21+
websocketUrl := fmt.Sprintf("%s/nodes/%s/vncwebsocket?port=%s&vncticket=%s", baseUrl, nodeName, vnc.Port, url.QueryEscape(vnc.Ticket))
22+
23+
conn, resp, err := c.websocketDialer().DialContext(ctx, websocketUrl, c.makeAuthHeaders())
24+
if err != nil {
25+
if resp != nil {
26+
return nil, errors.Errorf("failed to dial websocket: %v : %v", checkResponse(resp), err)
27+
}
28+
return nil, errors.Errorf("failed to dial websocket: %v", err)
29+
}
30+
31+
if err := conn.WriteMessage(websocket.BinaryMessage, []byte(fmt.Sprintf("%s:%s\n", vnc.User, vnc.Ticket))); err != nil {
32+
return nil, errors.Errorf("failed to start session: %v", err)
33+
}
34+
35+
return conn, nil
36+
}
37+
38+
func (c *RESTClient) websocketDialer() *websocket.Dialer {
39+
var tlsConfig *tls.Config
40+
transport := c.httpClient.Transport.(*http.Transport)
41+
if transport != nil {
42+
tlsConfig = transport.TLSClientConfig
43+
}
44+
return &websocket.Dialer{
45+
Proxy: http.ProxyFromEnvironment,
46+
HandshakeTimeout: 30 * time.Second,
47+
TLSClientConfig: tlsConfig,
48+
}
49+
}

0 commit comments

Comments
 (0)