外部程序如何通过novnc访问proxmox的虚拟机

# 外部程序如何通过novnc访问proxmox的虚拟机

# 1. proxmox工作原理

proxmox虚拟化平台管理界面可以通过novnc连接虚拟机,web管理页面基本都是通过api完成相关功能的,具体api文档可以点击右上角“documentation”查看:

1727141895203

1727141942825

novnc连接的步骤如下:

1.通过API:/api2/json/nodes/pve/qemu/105/vncproxy获取vnc的ticket

1727139468856

1727139497908

2.novnc通过websocket建立连接

1727139584632

1727139600354

websocket建立成功有一下几点注意的地方:

1.每个vnc的ticket只能建立一次连接,无论失败成功,再次使用无法建立连接报bad handshake错误

2.websocket连接请求中需要携带PVEAuthCookie,值是pve的认证ticket,proxmox系统登录后或者通过api获得(/api2/json/access/ticket)

3.连接过程中novnc需要输入密码,密码就是:vncticket的值(不能url编码),proxmox前端自动填入密码登录,因此登录过程未出现输入密码的过程。

1727140823899

4.PVEAuthCookie和ticket必须使用url编码

# 2.外部调用

开发第三方应用如何将novnc嵌入访问proxmox的虚拟机资源,就要满足websocket建立的三个条件

# 1.nginx代理

将第三方应用和proxmox管理页面用nginx代理到同一IP地址下,实现同源,这样访问时就可以自动携带PVEAuthCookie,这种方式的优势是不需要后端处理,前端模仿proxmox连接vnc的过程就可以。

缺点是:暴露了PVEAuthCookie,用户可以通过该值直接登录proxmox或者通过api控制proxmox,存在安全极大安全风险。

# 2.websocket代理

这种方式实现的原理是,websocket代理转发客户端和proxmox之间的连接流量,proxmox认证需要的信息由代理生成:

客户端————(websocket)—————代理————(websocket)————-proxmox

# 2.1 获取pve的token

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
type LoginResponse struct {
	Data struct {
		CSRFPreventionToken string `json:"CSRFPreventionToken"`
		Ticket              string `json:"ticket"`
	} `json:"data"`
}

func LoginProxmox(url, username, password string) (string, string, error) {
	// url := "https://192.168.100.5:8006/api2/json/access/ticket"
	loginData := map[string]string{
		"username": username,
		"password": password,
	}
	jsonData, _ := json.Marshal(loginData)

	req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // 忽略证书验证
		},
	}

	resp, err := client.Do(req)
	if err != nil {
		return "", "", err
	}
	defer resp.Body.Close()

	body, _ := ioutil.ReadAll(resp.Body)

	var loginResp LoginResponse
	err = json.Unmarshal(body, &loginResp)
	if err != nil {
		return "", "", err
	}

	return loginResp.Data.Ticket, loginResp.Data.CSRFPreventionToken, nil
}

# 2.2 获取vnc的ticket

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type VncResponse struct {
	Data struct {
		Ticket string `json:"ticket"`
		Port   string `json:"port"`
		User   string `json:"user"`
		Upid   string `json:"upid"`
		Cert   string `json:"cert"`
	} `json:"data"`
}

func GetVncTicket(url, ticket, csrfToken string) (VncResponse, error) {
	// url := fmt.Sprintf("https://192.168.100.5:8006/api2/json/nodes/%s/qemu/%s/vncproxy", node, vmid)

	// loginData := map[string]int{
	// 	"websocket": 1,
	// }
	// jsonData, _ := json.Marshal(loginData)

	req, _ := http.NewRequest("POST", url, nil)
	req.Header.Set("Cookie", "PVEAuthCookie="+ticket)
	req.Header.Set("csrfpreventiontoken", csrfToken)

	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // 忽略证书验证
		},
	}

	resp, err := client.Do(req)
	if err != nil {
		return VncResponse{}, err
	}
	defer resp.Body.Close()

	body, _ := ioutil.ReadAll(resp.Body)

	var vncResp VncResponse
	err = json.Unmarshal(body, &vncResp)
	if err != nil {
		return VncResponse{}, err
	}

	return vncResp, nil
}

# 2.3 建立websocket转发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// 代理 WebSocket 连接
func (app *App) proxyWebSocket(w http.ResponseWriter, r *http.Request, port string, vncticket string) {
	// 升级客户端请求为 WebSocket 连接
	clientConn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		http.Error(w, "Failed to upgrade to WebSocket", http.StatusBadRequest)
		log.Println("WebSocket upgrade error:", err)
		return
	}
	defer clientConn.Close()

	//更新vncTicket
	vncurl := fmt.Sprintf("https://192.168.100.5:8006/api2/json/nodes/%s/qemu/%d/vncproxy", "pve", 105)
	var vncRes pve.VncResponse
	vncRes, err = pve.GetVncTicket(vncurl, app.ticket, app.csrf)
	if err != nil {
		log.Fatal(err)
	}

	// 构造 Proxmox VNC WebSocket URL
	proxmoxURL := url.URL{
		Scheme:   "wss",
		Host:     "192.168.100.5:8006",
		Path:     "/api2/json/nodes/pve/qemu/105/vncwebsocket", // 替换 <node> 和 <vmid> 为实际值
		RawQuery: fmt.Sprintf("port=%s&vncticket=%s", app.port, url.QueryEscape(vncRes.Data.Ticket)),
	}

	dialer := websocket.Dialer{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // 忽略证书验证
		Subprotocols:    []string{"binary"},
	}

	headers := http.Header{}
	headers.Add("Cookie", "PVEAuthCookie="+url.QueryEscape(app.ticket))
	// headers.Add("csrfpreventiontoken", app.csrf)

	// 连接到 Proxmox WebSocket
	log.Println("URL:", proxmoxURL.String())
	proxmoxConn, _, err := dialer.Dial(proxmoxURL.String(), headers)
	if err != nil {
		http.Error(w, "Failed to connect to Proxmox", http.StatusInternalServerError)
		log.Println("Proxmox WebSocket connection error:", err)
		return
	}
	defer proxmoxConn.Close()

	// 启动客户端和 Proxmox 之间的双向数据传输
	done := make(chan struct{})

	// 从客户端到 Proxmox
	go func() {
		defer close(done)
		for {
			messageType, message, err := clientConn.ReadMessage()
			if err != nil {
				log.Println("Client read error:", err)
				return
			}

			err = proxmoxConn.WriteMessage(messageType, message)
			if err != nil {
				log.Println("Proxmox write error:", err)
				return
			}
		}
	}()

	// 从 Proxmox 到客户端
	go func() {
		for {
			messageType, message, err := proxmoxConn.ReadMessage()
			if err != nil {
				log.Println("Proxmox read error:", err)
				clientConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
				return
			}

			err = clientConn.WriteMessage(messageType, message)
			if err != nil {
				log.Println("Client write error:", err)
				return
			}
		}
	}()

	// 等待传输完成
	<-done
}

# 2.4 代理端口监听和处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func (app *App) Handler(w http.ResponseWriter, r *http.Request) {

	// 假设从请求中获取端口和 vncticket(也可以从 Proxmox API 获取)
	port := r.URL.Query().Get("port")
	vncticket := r.URL.Query().Get("vncticket")
	log.Println(vncticket)
	log.Println(port)

	if port == "" || vncticket == "" {
		http.Error(w, "Missing port or vncticket in query parameters", http.StatusBadRequest)
		return
	}
	app.proxyWebSocket(w, r, port, vncticket)

}
--------------------------------------------

# main.go

	http.HandleFunc("/", app.Handler)
	log.Println("WebSocket Proxy Server started at :8080")
	err = http.ListenAndServe(":8080", nil)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}

# 2.5 novnc连接

1727141277677

由于程序测试的缘故,需要提供路径参数,点击连接后出现密码输入框:

1727141376509

输入vncticket:PVEVNC:66F13904::KCOiBbp61SRDoRPfuJCU0dSiOTddunbMSmoNwIIlNvooFtbJO/BMscu7l61a83rr3PH8kOjvaXRxgHZRL24FvRFc5KZKLQsFcok0 rsB1QdLceiX05xVkhInHqCWbPSNKZy5j4cIzQZGCVNfFOcEZNFMSNzGJGEdxQ/6F9jYsJMvnyxIKHff90RIHtupvKWpn1Ou3pdWKX2BbnT20mHHLogxdLKlMxXbHYJ/ia/6WHKBUBaYTQwReH7K0mmSmElJXQW3iit R0FA52uMwS1sBG5Gl0WJWmWzcA8EnPV6wID0w/P 9NaG8Cd1C6XdUghsKaIj0wz0p2DJSDlfzymdLA==

连接成功

1727141642833

这种方法的缺点是后端的编程工作量稍大,但是由于代理的存在,用户端无需获得proxmox相关凭证,不会对proxmox造成安全威胁,后续业务逻辑处理起来也更加灵活。

# 3.结束语

本文介绍了最关键的novnc的代理方式,第三方应用如果想控制proxmox实现开关机、新建等功能也可以通过类似的的方式后端程序通过api进行控制proxmox。

comments powered by Disqus