209 lines
5.2 KiB
Go
209 lines
5.2 KiB
Go
package console
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"forgejo.digital-droplets.de/philschlo/proxui/platform/cluster"
|
|
"forgejo.digital-droplets.de/philschlo/proxui/platform/proxmox"
|
|
|
|
"proxui/backend/internal/auth"
|
|
"proxui/backend/internal/rbac"
|
|
)
|
|
|
|
type ClusterRepository interface {
|
|
GetCluster(ctx context.Context, id string) (cluster.Cluster, bool, error)
|
|
}
|
|
|
|
type VMRepository interface {
|
|
GetVM(ctx context.Context, profileID string, vmID string) (VMInfo, bool, error)
|
|
}
|
|
|
|
type VMInfo struct {
|
|
ID string
|
|
TenantID string
|
|
ClusterID string
|
|
ProxmoxVMID int
|
|
Node string
|
|
MembershipRole string
|
|
}
|
|
|
|
type ProxyClientFactory func(cluster.Cluster) (ProxyClient, error)
|
|
|
|
type ProxyClient interface {
|
|
GetVNCTicket(ctx context.Context, node string, vmid int) (proxmox.VNCInfo, error)
|
|
}
|
|
|
|
type Handler struct {
|
|
clusters ClusterRepository
|
|
vmRepo VMRepository
|
|
clientFactory ProxyClientFactory
|
|
signingKey []byte
|
|
}
|
|
|
|
func NewHandler(clusters ClusterRepository, vmRepo VMRepository, clientFactory ProxyClientFactory, signingKey string) Handler {
|
|
return Handler{
|
|
clusters: clusters,
|
|
vmRepo: vmRepo,
|
|
clientFactory: clientFactory,
|
|
signingKey: []byte(signingKey),
|
|
}
|
|
}
|
|
|
|
func (h Handler) CreateConsoleTicket(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := auth.PrincipalFromRequest(r)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
vmID := r.PathValue("vmID")
|
|
if vmID == "" {
|
|
writeError(w, http.StatusBadRequest, "vm_id_required")
|
|
return
|
|
}
|
|
|
|
vm, found, err := h.vmRepo.GetVM(r.Context(), principal.Subject, vmID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "vm_get_failed")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "vm_not_found")
|
|
return
|
|
}
|
|
|
|
if !rbac.Can(rbac.Role(vm.MembershipRole), rbac.ActionVMConsole) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
|
|
cluster, found, err := h.clusters.GetCluster(r.Context(), vm.ClusterID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "cluster_get_failed")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusBadGateway, "cluster_not_found")
|
|
return
|
|
}
|
|
|
|
client, err := h.clientFactory(cluster)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "proxmox_client_failed")
|
|
return
|
|
}
|
|
|
|
vncInfo, err := client.GetVNCTicket(r.Context(), vm.Node, vm.ProxmoxVMID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "proxmox_vnc_ticket_failed")
|
|
return
|
|
}
|
|
|
|
proxyTicket, err := h.signProxyTicket(proxyTicketPayload{
|
|
ClusterID: vm.ClusterID,
|
|
Node: vm.Node,
|
|
VMID: vm.ProxmoxVMID,
|
|
TenantID: vm.TenantID,
|
|
Endpoint: cluster.APIEndpoint,
|
|
VNC: vncTicket{VNCInfo: vncInfo},
|
|
ExpiresAt: time.Now().Add(15 * time.Minute),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ticket_sign_failed")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, consoleResponse{
|
|
Ticket: proxyTicket,
|
|
})
|
|
}
|
|
|
|
type vncTicket struct {
|
|
proxmox.VNCInfo
|
|
}
|
|
|
|
type proxyTicketPayload struct {
|
|
ClusterID string `json:"cluster_id"`
|
|
Node string `json:"node"`
|
|
VMID int `json:"vmid"`
|
|
TenantID string `json:"tenant_id"`
|
|
Endpoint string `json:"endpoint"`
|
|
VNC vncTicket `json:"vnc"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
Signature string `json:"signature"`
|
|
}
|
|
|
|
func (h Handler) signProxyTicket(payload proxyTicketPayload) (string, error) {
|
|
payload.Signature = ""
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
mac := hmac.New(sha256.New, h.signingKey)
|
|
mac.Write(data)
|
|
payload.Signature = base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
result, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return base64.RawURLEncoding.EncodeToString(result), nil
|
|
}
|
|
|
|
func VerifyProxyTicket(ticket string, signingKey []byte) (proxyTicketPayload, error) {
|
|
data, err := base64.RawURLEncoding.DecodeString(ticket)
|
|
if err != nil {
|
|
return proxyTicketPayload{}, fmt.Errorf("invalid ticket encoding")
|
|
}
|
|
|
|
var payload proxyTicketPayload
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
return proxyTicketPayload{}, fmt.Errorf("invalid ticket payload")
|
|
}
|
|
|
|
if time.Now().After(payload.ExpiresAt) {
|
|
return proxyTicketPayload{}, fmt.Errorf("ticket expired")
|
|
}
|
|
|
|
receivedSig := payload.Signature
|
|
payload.Signature = ""
|
|
dataToVerify, _ := json.Marshal(payload)
|
|
|
|
mac := hmac.New(sha256.New, signingKey)
|
|
mac.Write(dataToVerify)
|
|
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
if !hmac.Equal([]byte(receivedSig), []byte(expectedSig)) {
|
|
return proxyTicketPayload{}, fmt.Errorf("invalid ticket signature")
|
|
}
|
|
|
|
payload.Signature = receivedSig
|
|
return payload, nil
|
|
}
|
|
|
|
type consoleResponse struct {
|
|
Ticket string `json:"ticket"`
|
|
}
|
|
|
|
func DefaultProxyClientFactory(cluster cluster.Cluster) (ProxyClient, error) {
|
|
return proxmox.NewClient(cluster)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, message string) {
|
|
writeJSON(w, status, map[string]string{"error": message})
|
|
} |